from __future__ import annotations import re from .background import RGB from .config import RenderConfig, StyleSpec from .labels import normalize_label, skip_oversize_element, style_key from .markup import ( _split_math_vars, escape_typst_string, has_bare_latex, has_malformed_typst_math, has_unbalanced_math_tags, is_pure_math_text, parse_toc_entries, to_typst_markup, to_typst_native, ) CMARKER_VERSION = "0.1.8" MITEX_VERSION = "0.2.6" # Detects legacy LaTeX inside tags (backslash commands like \frac, \sum) _LATEX_IN_MATH = re.compile(r"]*>[^<]*\\[a-zA-Z]", re.DOTALL) # Detects bare Typst math function calls (frac(...), sqrt(...), etc.) outside tags _BARE_TYPST_MATH = re.compile( r"(?:^|[^a-zA-Z])(?:frac|sqrt|root|binom|sum|prod|integral|mat|vec|cases|abs|norm|floor|ceil)\s*\(", re.IGNORECASE, ) _FIT_HELPERS = """\ #let pdftr_fit_size(lo, hi, eps, fits) = { if hi - lo <= eps { lo } else { let mid = lo + (hi - lo) / 2 if fits(mid) { pdftr_fit_size(mid, hi, eps, fits) } else { pdftr_fit_size(lo, mid, eps, fits) } } } #let pdftr_floor_size(value, floor) = if value < floor { floor } else { value } #let pdftr_floor_leading(value, floor) = if value < floor { floor } else { value } #let pdftr_fit_markdown(markdown, max_size: 10pt, min_size: 9pt, max_leading: 0.66em, min_leading: 0.54em, fit_height: none, weight: "regular", style: "normal", eps: 0.08pt, math: none) = { layout(size => { let allowed-height = if fit_height == none { size.height } else { calc.min(size.height, fit_height) } let render(text_size, leading) = block(width: size.width)[#{ set text(size: text_size, weight: weight, style: style) set par(leading: leading) cmarker.render(markdown, math: math) }] let fits(text_size, leading) = measure(width: size.width, render(text_size, leading)).height <= allowed-height if fits(max_size, max_leading) { render(max_size, max_leading) } else { let fallback_min_size = pdftr_floor_size(min_size - 1.6pt, 5.4pt) let fallback_min_leading = pdftr_floor_leading(min_leading - 0.12em, 0.14em) let emergency_min_size = pdftr_floor_size(fallback_min_size - 1.2pt, 4.8pt) let emergency_min_leading = pdftr_floor_leading(fallback_min_leading - 0.08em, 0.10em) let chosen_leading = if fits(min_size, max_leading) { max_leading } else { min_leading } let chosen_size = if not fits(min_size, chosen_leading) { let fallback_leading = pdftr_floor_leading(chosen_leading - 0.12em, fallback_min_leading) let emergency_leading = pdftr_floor_leading(fallback_leading - 0.08em, emergency_min_leading) if not fits(fallback_min_size, fallback_leading) { if not fits(emergency_min_size, emergency_leading) { emergency_min_size } else { pdftr_fit_size(emergency_min_size, fallback_min_size, eps, size_pt => fits(size_pt, emergency_leading)) } } else { pdftr_fit_size(fallback_min_size, min_size, eps, size_pt => fits(size_pt, fallback_leading)) } } else { pdftr_fit_size(min_size, max_size, eps, size_pt => fits(size_pt, chosen_leading)) } let final_leading = if fits(min_size, chosen_leading) { chosen_leading } else if fits(fallback_min_size, pdftr_floor_leading(chosen_leading - 0.12em, fallback_min_leading)) { pdftr_floor_leading(chosen_leading - 0.12em, fallback_min_leading) } else { emergency_min_leading } render(chosen_size, final_leading) } }) } #let pdftr_fit_typst(content, max_size: 10pt, min_size: 9pt, max_leading: 0.66em, min_leading: 0.54em, fit_height: none, weight: "regular", style: "normal", eps: 0.08pt, no_wrap: false) = { layout(size => { let allowed-height = if fit_height == none { size.height } else { calc.min(size.height, fit_height) } let render(text_size, leading) = block(width: size.width)[#{ set text(size: text_size, weight: weight, style: style) set par(leading: leading) content }] if no_wrap { // Single-line mode: find largest font where content does not wrap. // Compare height at container width vs height at huge width — equal means no wrap. let no_wrap_fits(text_size) = { let h_narrow = measure(width: size.width, render(text_size, max_leading)).height let h_wide = measure(width: 10000pt, block(width: 10000pt)[#{ set text(size: text_size, weight: weight, style: style) set par(leading: max_leading) content }]).height h_narrow <= h_wide } let chosen_size = if no_wrap_fits(max_size) { max_size } else if not no_wrap_fits(min_size) { min_size } else { pdftr_fit_size(min_size, max_size, eps, size_pt => no_wrap_fits(size_pt)) } render(chosen_size, max_leading) } else { let fits(text_size, leading) = measure(width: size.width, render(text_size, leading)).height <= allowed-height if fits(max_size, max_leading) { render(max_size, max_leading) } else { let fallback_min_size = pdftr_floor_size(min_size - 1.6pt, 5.4pt) let fallback_min_leading = pdftr_floor_leading(min_leading - 0.12em, 0.14em) let emergency_min_size = pdftr_floor_size(fallback_min_size - 1.2pt, 4.8pt) let emergency_min_leading = pdftr_floor_leading(fallback_min_leading - 0.08em, 0.10em) let chosen_leading = if fits(min_size, max_leading) { max_leading } else { min_leading } let chosen_size = if not fits(min_size, chosen_leading) { let fallback_leading = pdftr_floor_leading(chosen_leading - 0.12em, fallback_min_leading) let emergency_leading = pdftr_floor_leading(fallback_leading - 0.08em, emergency_min_leading) if not fits(fallback_min_size, fallback_leading) { if not fits(emergency_min_size, emergency_leading) { emergency_min_size } else { pdftr_fit_size(emergency_min_size, fallback_min_size, eps, size_pt => fits(size_pt, emergency_leading)) } } else { pdftr_fit_size(fallback_min_size, min_size, eps, size_pt => fits(size_pt, fallback_leading)) } } else { pdftr_fit_size(min_size, max_size, eps, size_pt => fits(size_pt, chosen_leading)) } let final_leading = if fits(min_size, chosen_leading) { chosen_leading } else if fits(fallback_min_size, pdftr_floor_leading(chosen_leading - 0.12em, fallback_min_leading)) { pdftr_floor_leading(chosen_leading - 0.12em, fallback_min_leading) } else { emergency_min_leading } render(chosen_size, final_leading) } } }) }""" def _rgb_typst(rgb: RGB) -> str: return f"rgb({rgb[0]}, {rgb[1]}, {rgb[2]})" def _font_typst(font: str | list[str]) -> str: """Render Typst font expression. Accepts a single name or fallback chain.""" if isinstance(font, str): return f'"{font}"' return "(" + ", ".join(f'"{f}"' for f in font) + ")" def _style_for(label: str, cfg: RenderConfig) -> StyleSpec: return cfg.styles.get(style_key(label), cfg.default_style) def _cover_rect( var: str, x0: float, y0: float, x1: float, y1: float, bg: RGB, pad: float ) -> str: w = max(4.0, x1 - x0 + 2 * pad) h = max(4.0, y1 - y0 + 2 * pad) dx = x0 - pad dy = y0 - pad return ( f"#let {var}_cover = rect(width: {w:.2f}pt, height: {h:.2f}pt," f" fill: {_rgb_typst(bg)}, stroke: none)\n" f"#context {{ place(top + left, dx: {dx:.2f}pt, dy: {dy:.2f}pt, {var}_cover) }}\n" ) def _downward_avail_height( bbox: list[float], all_bboxes: list[list[float]], page_height: float, max_expand: float, ) -> float: """Block height that lets text overflow DOWN into empty space only. Extends the tight bbox height downward until it would reach the nearest element below that horizontally overlaps it — capped by ``max_expand`` and the page bottom. Mirrors the sizing heuristic: the Typst fit then keeps the chosen size when overflow lands in empty space and only shrinks when the text would actually collide with a neighbor below. """ x0, y0, x1, y1 = bbox limit = min(page_height, y1 + max_expand) for ob in all_bboxes: if ob is bbox or len(ob) != 4: continue ox0, oy0, ox1, _ = ob if ox1 > x0 and ox0 < x1 and oy0 >= y1 - 0.5: # below & horizontally overlaps limit = min(limit, oy0) return max(y1 - y0, limit - y0) def _rightward_avail_width( bbox: list[float], all_bboxes: list[list[float]], page_width: float, ) -> float: """Block width that lets single-line text extend RIGHT into empty space only. Extends the tight bbox width rightward until it would reach the nearest element to the right that vertically overlaps it — capped by the page edge. Without this, a single-line block expands to the page margin and can overrun a right-hand neighbor (e.g. a TOC page number) without shrinking; bounding it lets the no-wrap fit shrink the text before it collides. """ x0, y0, x1, y1 = bbox limit = page_width for ob in all_bboxes: if ob is bbox or len(ob) != 4: continue ox0, oy0, _, oy1 = ob if ox0 >= x1 - 0.5 and oy0 < y1 and oy1 > y0: # right & vertically overlaps limit = min(limit, ox0) return max(x1 - x0, limit - x0) def _text_block( var: str, x0: float, y0: float, x1: float, y1: float, markdown: str, font_size: float, min_font: float, weight: str, style_: str, text_color: RGB, font_family: str, expanded_w: float | None = None, expanded_h: float | None = None, valign: str = "top", ) -> str: w = max(4.0, expanded_w if expanded_w is not None else (x1 - x0)) h = max(4.0, expanded_h if expanded_h is not None else (y1 - y0)) # Ensure min_size <= max_size; otherwise the binary-search fit helper # gets lo > hi and behaves incorrectly. effective_min = min(min_font, font_size) escaped = escape_typst_string(markdown) fit_call = ( f"pdftr_fit_markdown({var}_md," f" max_size: {font_size:.2f}pt, min_size: {effective_min:.2f}pt," f' weight: "{weight}", style: "{style_}")' ) if valign == "bottom": content = f"align(bottom + left, {fit_call})" elif valign == "center": content = f"align(center + horizon, {fit_call})" else: content = fit_call return ( f'#let {var}_md = "{escaped}"\n' f"#let {var}_body = block(width: {w:.2f}pt, height: {h:.2f}pt)[#{{\n" f" set text(font: {_font_typst(font_family)}, fill: {_rgb_typst(text_color)})\n" f" {content}\n" f"}}]\n" f"#context {{ place(top + left, dx: {x0:.2f}pt, dy: {y0:.2f}pt, {var}_body) }}\n" ) def _text_block_typst( var: str, x0: float, y0: float, x1: float, y1: float, typst_markup: str, font_size: float, min_font: float, weight: str, style_: str, text_color: RGB, font_family: str, no_wrap: bool = False, expanded_w: float | None = None, expanded_h: float | None = None, valign: str = "top", ) -> str: w = max(4.0, expanded_w if expanded_w is not None else (x1 - x0)) h = max(4.0, expanded_h if expanded_h is not None else (y1 - y0)) effective_min = min(min_font, font_size) no_wrap_arg = ", no_wrap: true" if no_wrap else "" fit_call = ( f"pdftr_fit_typst({var}_tm," f" max_size: {font_size:.2f}pt, min_size: {effective_min:.2f}pt," f' weight: "{weight}", style: "{style_}"{no_wrap_arg})' ) if valign == "center": content = f"align(center + horizon, {fit_call})" elif valign == "bottom": content = f"align(bottom + left, {fit_call})" else: content = fit_call return ( f"#let {var}_tm = [{typst_markup}]\n" f"#let {var}_body = block(width: {w:.2f}pt, height: {h:.2f}pt)[#{{\n" f" set text(font: {_font_typst(font_family)}, fill: {_rgb_typst(text_color)})\n" f" {content}\n" f"}}]\n" f"#context {{ place(top + left, dx: {x0:.2f}pt, dy: {y0:.2f}pt, {var}_body) }}\n" ) _TOC_TOP_LEVEL_RE = re.compile(r"^\d+\s+\S") def _toc_block( var: str, x0: float, y0: float, x1: float, y1: float, translated_text: str, font_size: float, min_font: float, text_color: RGB, font_family: str, rendered_pages: set[int] | None = None, ) -> str: """Render TOC entries with right-aligned page numbers + clickable links. Each entry whose target page is in `rendered_pages` (1-indexed) gets wrapped in `#link()[...]` so clicking jumps to that page. Top-level entries (section number with no dot, e.g. '1 Introduction') render in bold; sub-entries stay regular. Auto-shrinks via `pdftr_fit_typst` when entries overflow the bbox. """ entries = parse_toc_entries(translated_text) markup_lines: list[str] = [] for title, page_num in entries: title_escaped = escape_typst_string(to_typst_markup(title)) weight = "bold" if _TOC_TOP_LEVEL_RE.match(title) else "regular" if page_num: row = ( f"grid(columns: (1fr, auto), gutter: 4pt, " f'text(weight: "{weight}", "{title_escaped} "), ' f'align(right, text(weight: "{weight}", "{page_num}")))' ) page_int = int(page_num) if rendered_pages is None or page_int in rendered_pages: markup_lines.append(f"#link()[#{row}]") else: markup_lines.append(f"#{row}") else: markup_lines.append(f'#par(text(weight: "{weight}", "{title_escaped}"))') typst_markup = "\n".join(markup_lines) return _text_block_typst( var, x0, y0, x1, y1, typst_markup, font_size, min_font, "regular", "normal", text_color, font_family, ) def build_typst_source( parsed: dict, sizes: dict[str, float], bg_colors: dict[str, RGB], text_colors: dict[str, RGB], cfg: RenderConfig, fallback_vars: set[str] | frozenset[str] = frozenset(), ) -> str: """Build the overlay Typst source. Args: fallback_vars: element vars (e.g. ``e3_7``) whose markup previously broke the Typst compile — rendered via the plain markdown-string path instead of native Typst markup (always syntactically valid). """ lines: list[str] = [ f"#set text(font: {_font_typst(cfg.font_family)})", f'#import "@preview/cmarker:{CMARKER_VERSION}"', _FIT_HELPERS, ] pages = parsed.get("pages", []) # 1-indexed anchor numbers actually emitted (enumerate position of each # rendered page) — used to gate TOC links so we never #link a missing # anchor (Typst errors on dangling label references). rendered_pages = { page_idx + 1 for page_idx, page in enumerate(pages) if cfg.pages is None or page.get("page_index", page_idx) in cfg.pages } for page_idx, page in enumerate(pages): if cfg.pages is not None and page.get("page_index", page_idx) not in cfg.pages: continue pw = page.get("page_width", 595.0) ph = page.get("page_height", 842.0) lines.append( f"#set page(width: {pw:.2f}pt, height: {ph:.2f}pt, margin: 0pt, fill: none)" ) # Anchor for TOC links — page number is 1-indexed (user-facing). lines.append(f"#metadata(none)") elems = page.get("elements", []) # All element bboxes on this page — used to bound downward text overflow # so a block only shrinks when its overflow would hit a neighbor. page_bboxes = [e.get("bbox_pdf") for e in elems if e.get("bbox_pdf")] for elem_idx, elem in enumerate(elems): category = elem.get("category", "") label = normalize_label(elem.get("label", "Text")) uid = f"p{page_idx}:e{elem_idx}" var = f"e{page_idx}_{elem_idx}" if category == "BYPASS": continue bbox = elem.get("bbox_pdf", [0, 0, 100, 20]) x0, y0, x1, y1 = bbox # A minor/structural element (section header, caption, footnote, …) # covering most of the page is a mis-detection — keep the original. # Real content (tables, body text, equations) is never skipped by # size. The redaction pass uses this same predicate, so the two # stages always agree on what to skip (else content gets erased but # not redrawn). if skip_oversize_element(label, bbox, pw, ph): continue bg = bg_colors.get(uid, cfg.background.fallback_bg) tc = text_colors.get(uid, (0, 0, 0)) style = _style_for(label, cfg) font_size = sizes.get(uid, cfg.sizing.fallback_size) if category == "EQUATION": translated = elem.get("translated_text") or "" source = elem.get("source_text") or "" if not translated or translated == source: continue # Malformed / bare-LaTeX output → preserve original text layer. # For EQUATION, skip is_pure_math_text: translated_text is intentionally # Typst math markup and should be rendered even if it has no prose. if ( has_unbalanced_math_tags(translated) or has_bare_latex(translated) or has_malformed_typst_math(translated) ): continue if category != "EQUATION" and is_pure_math_text(translated): continue lines.append( _cover_rect( var, x0, y0, x1, y1, bg, cfg.background.eraser_padding_pt ) ) # EQUATION elements that reach here always contain prose mixed # with math (pure-math equations have no translated_text and are # skipped above). Fractions make the bbox taller than the actual # text size, so use the cluster font_size, not y1 - y0. eq_max_size = font_size if var not in fallback_vars and ( " tags) — wrap in $ and use native path typst_markup = f"${_split_math_vars(translated)}$" is_single_line = (y1 - y0) < font_size * 1.8 exp_w = ( _rightward_avail_width(bbox, page_bboxes, pw) if is_single_line else None ) exp_h = ( _downward_avail_height(bbox, page_bboxes, ph, cfg.max_expand_pt) if cfg.expand_downward and not is_single_line else None ) lines.append( _text_block_typst( var, x0, y0, x1, y1, typst_markup, font_size, cfg.min_font_size_pt, style.weight, style.style_, tc, cfg.font_family, no_wrap=is_single_line, expanded_w=exp_w, expanded_h=exp_h, ) ) else: # No tags, no Typst functions — plain text/LaTeX, use cmarker/mitex markdown = to_typst_markup(translated) is_single_line = (y1 - y0) < font_size * 1.8 exp_w = ( _rightward_avail_width(bbox, page_bboxes, pw) if is_single_line else None ) exp_h = ( _downward_avail_height(bbox, page_bboxes, ph, cfg.max_expand_pt) if cfg.expand_downward and not is_single_line else None ) lines.append( _text_block( var, x0, y0, x1, y1, markdown, font_size, cfg.min_font_size_pt, style.weight, style.style_, tc, cfg.font_family, expanded_w=exp_w, expanded_h=exp_h, ) ) # Add pagebreak between pages (not after the last one) if page_idx < len(pages) - 1: lines.append("#pagebreak()") return "\n".join(lines) + "\n"