From 4ba7cfd1c535dcd1ec19f7fc50360528349447a6 Mon Sep 17 00:00:00 2001 From: Aaron Luk Date: Sat, 27 Jun 2026 11:08:32 -0700 Subject: [PATCH 1/2] Surface silent PDF rendering loss: gate dropped glyphs, report overflow, fix \setminus Two changes so the PDF build stops silently dropping content from the rendered output. The PDF opens fine, but individual math glyphs go missing with no error surfaced -- so a downstream spec build shipped with missing symbols unnoticed. 1. PDF quality gate (#100). The stderr filter previously suppressed every Overfull \hbox warning and let "Missing character" warnings scroll past without failing the build. Now: - Missing-character / unrepresentable-glyph warnings FAIL the build by default (a silently dropped character is meaning-changing); --no-check-glyphs downgrades to a warning. - Overfull \hbox (Xpt too wide) are collected and REPORTED worst-first, non-fatal by default; --check-overflow makes them fatal, --overflow-threshold-pt sets the cutoff (default 1.0pt). - Underfull boxes still ignored. 2. \setminus remap. unicode-math maps \setminus to U+29F5 (reverse solidus operator), which no bundled math font provides (DejaVuMathTeXGyre and latinmodern-math both lack it; tectonic ships only latinmodern-math.otf). It rendered as a blank box. Remapped to U+2216 (standard set-minus), which DejaVuMathTeXGyre has. Resolves #100. Co-Authored-By: Claude Opus 4.8 (1M context) --- doc_build/doc_builder.py | 144 +++++++++++++++++- .../template/after-header-includes.latex | 6 + 2 files changed, 146 insertions(+), 4 deletions(-) diff --git a/doc_build/doc_builder.py b/doc_build/doc_builder.py index 000ad06..3faa0b8 100644 --- a/doc_build/doc_builder.py +++ b/doc_build/doc_builder.py @@ -80,6 +80,95 @@ def __lshift__(self, msg): log = Logger() +# --- PDF quality gate (aousd/doc_build#100) ------------------------------- +# tectonic reports two classes of rendering defect that doc_build historically +# either suppressed (Overfull \hbox) or let scroll past unnoticed (Missing +# character). Both silently degrade the published PDF -- content runs off the +# right margin, or a glyph is dropped entirely with no error. These helpers +# surface them, and optionally fail the build. +_OVERFULL_RE = re.compile(r"Overfull \\hbox \(([0-9.]+)pt too wide\)") +# Codepoint as printed by tectonic/XeTeX: U+29F5, 0x302, or TeX hex "302. +_GLYPH_CP_RE = re.compile(r'(?:0x|U\+|")([0-9A-Fa-f]+)') + + +def _report_pdf_diagnostics( + overflows, + missing_glyphs, + *, + check_glyphs, + check_overflow, + overflow_threshold_pt, +): + """Report (and optionally fail on) PDF rendering defects tectonic hides. + + ``overflows`` is a list of ``(pt, line)`` for "Overfull \\hbox (Xpt too + wide)" warnings (content past the right text margin). ``missing_glyphs`` is + a list of the raw "Missing character"/"could not represent" warning lines + (characters silently dropped from the PDF). + + By default a missing glyph FAILS the build (silent, meaning-changing + corruption) while margin overflow is only REPORTED -- pass + ``check_overflow`` to make overflow fatal too. See aousd/doc_build#100. + """ + over = sorted( + ((pt, line) for pt, line in overflows if pt >= overflow_threshold_pt), + reverse=True, + ) + + if over: + log( + f"\n[doc_build] Right-margin overflow: {len(over)} line(s) exceed the " + f"text width by >= {overflow_threshold_pt}pt (worst {over[0][0]:.1f}pt):", + file=sys.stderr, + ) + for pt, line in over[:20]: + log(f" {pt:8.2f}pt {line}", file=sys.stderr) + if len(over) > 20: + log(f" ... and {len(over) - 20} more", file=sys.stderr) + + if missing_glyphs: + counts = {} + for line in missing_glyphs: + m = _GLYPH_CP_RE.search(line) + key = f"U+{int(m.group(1), 16):04X}" if m else "?" + counts[key] = counts.get(key, 0) + 1 + summary = ", ".join(f"{k} x{v}" for k, v in sorted(counts.items())) + log( + f"\n[doc_build] Missing glyphs (silently dropped from the PDF): " + f"{len(missing_glyphs)} warning(s) [{summary}]", + file=sys.stderr, + ) + for line in missing_glyphs[:20]: + log(f" {line}", file=sys.stderr) + if len(missing_glyphs) > 20: + log(f" ... and {len(missing_glyphs) - 20} more", file=sys.stderr) + + failures = [] + if check_glyphs and missing_glyphs: + failures.append( + f"{len(missing_glyphs)} missing-glyph warning(s) -- characters were " + "silently dropped from the PDF" + ) + if check_overflow and over: + failures.append( + f"{len(over)} line(s) overflow the right margin by " + f">= {overflow_threshold_pt}pt" + ) + + if failures: + raise SystemExit( + "[doc_build] PDF quality gate failed (aousd/doc_build#100):\n - " + + "\n - ".join(failures) + + "\n(pass --no-check-glyphs and/or omit --check-overflow to " + "downgrade these to warnings)" + ) + if over or missing_glyphs: + log( + "[doc_build] PDF diagnostics reported above (non-fatal).", + file=sys.stderr, + ) + + class ExecCommand: def __init__(self, binary_name): if binary := shutil.which(binary_name): @@ -470,6 +559,8 @@ def _render_combined( def stderr_processor(std_err): lines = std_err.splitlines() + overflows = [] # (pt: float, line: str) + missing_glyphs = [] # raw warning lines for line in lines: # Spurious warning: https://github.com/tectonic-typesetting/tectonic/discussions/1192#discussioncomment-9463365 @@ -477,10 +568,10 @@ def stderr_processor(std_err): "warning: Trying to include PDF file with version " ): continue - # Can be safely ignored: https://www.overleaf.com/learn/how-to/Understanding_underfull_and_overfull_box_warnings - if line.startswith("warning: texput.") and ( - "Overfull " in line or "Underfull " in line - ): + # Underfull boxes are cosmetic (loose inter-word spacing), + # never a margin overflow -- keep ignoring them. + # https://www.overleaf.com/learn/how-to/Understanding_underfull_and_overfull_box_warnings + if "Underfull " in line: continue # Can also be safely ignored if line.startswith("warning: accessing absolute path "): @@ -490,8 +581,32 @@ def stderr_processor(std_err): if line.startswith("warning: warnings were issued"): continue + # Overfull \hbox (Xpt too wide): content runs past the right + # text margin. Collect (don't silently drop) so it can be + # reported and optionally gated. See aousd/doc_build#100. + m = _OVERFULL_RE.search(line) + if m: + overflows.append((float(m.group(1)), line)) + continue + # A character with no glyph in the chosen font is silently + # omitted from the PDF -- meaning-changing corruption. + if ( + "Missing character:" in line + or "could not represent character" in line + ): + missing_glyphs.append(line) + continue + log(line, file=sys.stderr) + _report_pdf_diagnostics( + overflows, + missing_glyphs, + check_glyphs=not args.no_check_glyphs, + check_overflow=args.check_overflow, + overflow_threshold_pt=args.overflow_threshold_pt, + ) + pdf_extra = [f"--include-in-header={latex_diff_preamble}"] if is_diff else [] latex_cmd_base = shared_command + [ f"--template={latex_template}", @@ -1417,6 +1532,27 @@ def make_build_parser(self, subparsers): "script to recreate the PDF from the .tex (implies tectonic wrapper)", action="store_true", ) + # PDF quality gate (aousd/doc_build#100). + build_parser.add_argument( + "--no-check-glyphs", + help="Do not fail the PDF build when characters are silently dropped " + "(missing-glyph warnings); report them only. Default: fail.", + action="store_true", + ) + build_parser.add_argument( + "--check-overflow", + help="Fail the PDF build when content overflows the right text margin " + "by >= --overflow-threshold-pt. Default: report only.", + action="store_true", + ) + build_parser.add_argument( + "--overflow-threshold-pt", + type=float, + default=1.0, + metavar="PT", + help="Minimum right-margin overflow, in points, to report or gate on. " + "Default: 1.0 (ignores sub-point rounding noise).", + ) build_parser.add_argument( "--heading-case-lint", help="Run the ISO heading sentence-case linter before building and " diff --git a/doc_build/template/after-header-includes.latex b/doc_build/template/after-header-includes.latex index 5cd80ae..b49ee6e 100644 --- a/doc_build/template/after-header-includes.latex +++ b/doc_build/template/after-header-includes.latex @@ -199,6 +199,12 @@ $endif$ \setmathfont{DejaVuMathTeXGyre.ttf}[ Path = $dejavufontpath$, ] +% \setminus maps (via unicode-math) to U+29F5 (reverse-solidus operator), which +% no bundled math font provides, so it is SILENTLY dropped from the PDF (rendered +% as a blank/tofu box, with no build error). Render it as U+2216 (set minus) -- +% the standard set-minus glyph -- which DejaVuMathTeXGyre provides natively. +% See aousd/doc_build#100. +\AtBeginDocument{\renewcommand{\setminus}{\mathbin{\char"2216}}} \setsansfont{DejaVuSans.ttf}[ Path = $dejavufontpath$, From b517fff56218da76c839dea7c58aee55d64b49c3 Mon Sep 17 00:00:00 2001 From: Aaron Luk Date: Tue, 30 Jun 2026 15:31:45 -0700 Subject: [PATCH 2/2] Make the PDF gate flags robust to non-argparse callers The diff self-test invokes the builder with a types.SimpleNamespace args object that has none of the new gate flags, so args.no_check_glyphs raised AttributeError. Read the three flags via getattr, and define their defaults as module-level constants (GATE_DEFAULT_*) referenced by both the argparse `default=` and the getattr fallbacks -- single source of truth, so the CLI and programmatic-caller paths cannot drift apart. Co-Authored-By: Claude Opus 4.8 (1M context) --- doc_build/doc_builder.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/doc_build/doc_builder.py b/doc_build/doc_builder.py index 3faa0b8..8f1ec2b 100644 --- a/doc_build/doc_builder.py +++ b/doc_build/doc_builder.py @@ -54,6 +54,14 @@ def contextlib_chdir(path): DIFF_AFTER_FILENAME_TEMPLATE = "{base}.after_{to_short}" DIFF_DIFF_FILENAME_TEMPLATE = "{base}.diff_{from_short}_to_{to_short}" +# PDF quality-gate defaults (aousd/doc_build#100). Single source of truth shared +# by the CLI (argparse `default=`) and programmatic callers that don't go through +# argparse (e.g. the diff self-test passes a SimpleNamespace), so the two paths +# cannot drift apart. +GATE_DEFAULT_NO_CHECK_GLYPHS = False +GATE_DEFAULT_CHECK_OVERFLOW = False +GATE_DEFAULT_OVERFLOW_THRESHOLD_PT = 1.0 + class _ZeroToTwoArgsAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): @@ -602,9 +610,13 @@ def stderr_processor(std_err): _report_pdf_diagnostics( overflows, missing_glyphs, - check_glyphs=not args.no_check_glyphs, - check_overflow=args.check_overflow, - overflow_threshold_pt=args.overflow_threshold_pt, + check_glyphs=not getattr( + args, "no_check_glyphs", GATE_DEFAULT_NO_CHECK_GLYPHS), + check_overflow=getattr( + args, "check_overflow", GATE_DEFAULT_CHECK_OVERFLOW), + overflow_threshold_pt=getattr( + args, "overflow_threshold_pt", + GATE_DEFAULT_OVERFLOW_THRESHOLD_PT), ) pdf_extra = [f"--include-in-header={latex_diff_preamble}"] if is_diff else [] @@ -1538,17 +1550,19 @@ def make_build_parser(self, subparsers): help="Do not fail the PDF build when characters are silently dropped " "(missing-glyph warnings); report them only. Default: fail.", action="store_true", + default=GATE_DEFAULT_NO_CHECK_GLYPHS, ) build_parser.add_argument( "--check-overflow", help="Fail the PDF build when content overflows the right text margin " "by >= --overflow-threshold-pt. Default: report only.", action="store_true", + default=GATE_DEFAULT_CHECK_OVERFLOW, ) build_parser.add_argument( "--overflow-threshold-pt", type=float, - default=1.0, + default=GATE_DEFAULT_OVERFLOW_THRESHOLD_PT, metavar="PT", help="Minimum right-margin overflow, in points, to report or gate on. " "Default: 1.0 (ignores sub-point rounding noise).",