Skip to content

Arabic joins in the SVG preview: paint the shaped glyphs, not their characters - #293

Merged
fschutt merged 2 commits into
masterfrom
fix/arabic-shaping
Oct 29, 2026
Merged

fschutt merged 2 commits into
masterfrom
fix/arabic-shaping

Conversation

@fschutt

@fschutt fschutt commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Arabic in the SVG preview (PdfPage::to_svg, the web demo's page view) showed every letter in its isolated form. The PDFs themselves were always shaped correctly. Two commits.

What

  • render: the preview paints the shaped glyph ids (cc83851). render_text_items_to_svg wrote each TextItem::GlyphIds glyph back out as its Unicode character, one <text> per glyph, and the browser shaped every character again on its own. A lone letter can't join, so contextual forms, lam-alef and Indic conjuncts fell apart. A parsed PDF's visual-order TJ run was also bidi-reversed a second time. Glyph-id runs of an external font are now drawn from the glyph outlines: one <path> per (font, glyph) in <defs>, one <use> per glyph, advanced with the PDF's own /W widths, Tc and TJ adjustments. The <text> stays, with fill-opacity="0", so the preview is still selectable and searchable. TextItem::Text, the builtin fonts and faces without outlines render as before.
  • fonts: CID-keyed CFF widths (ae5cdac). cff_charstring_width_delta returned 0 for a glyph whose hints start in a subroutine (語 in NotoSansJP), so the rest of a parsed Japanese line was drawn on top of it. The new preview depends on correct widths, which is how this showed up.

Tests

  • tests/arabic_shaping.rs, with a 5 KB Vazirmatn subset (OFL, no Reserved Font Name), pinned to HarfBuzz's output for "مرحبا بالعالم لا":
    • from_html gives the contextual-form glyph ids and one lam-alef ligature, right to left;
    • the content stream carries those ids, and ToUnicode maps them back to the text;
    • the preview, direct and from a parsed PDF, paints exactly those ids at the PDF's positions. These two failed on master.
  • tests/parsed_cff_widths.rs failed before the font fix.
  • cargo test: 218 passed. --no-default-features: 110 passed. The text_layout-only check, --examples and both wasm32 checks pass.

Checked by eye

Persian and Urdu (Naskh), Hebrew, Hindi and Thai: the preview now matches the PDF.

Not fixed here (azul-layout, reported there)

  • Glyphs from a one-to-many GSUB substitution get the next character's cluster. Fonts with separate dot glyphs (Noto Sans Arabic, SF Arabic) put the dots on the wrong letter.
  • GPOS mark and cursive anchors are ignored, so harakat sit at offset 0 and Nastaliq loses its slanted baseline.
  • On macOS, rust-fontconfig's default Arabic font is Geeza Pro, which has no OpenType GSUB, so HTML that doesn't name an Arabic font stays unshaped.

🤖 Generated with Claude Code

fschutt and others added 2 commits October 28, 2026 21:52
…start in a subroutine

A bare CFF program (/FontFile3 /CIDFontType0C) has no hmtx, so
wrap_bare_cff_as_sfnt synthesizes one from each charstring's optional
leading width operand, decided at the first stack-clearing operator.
cff_charstring_width_delta returned None both for "that operator had no
width operand" and for "the subroutine returned without one", so when the
first hint operator sat in a subroutine and had an even operand count, the
caller kept scanning past it and read the next hint group's count instead.

NotoSansJP's 語 starts with 21 stem operands and a callsubr; the subroutine
pushes one more and runs hstemhm (22, no width: defaultWidthX 1000). The
scanner went on to the next cntrmask, found an odd count and took the first
stem coordinate (-30) as the width: advance 0, so everything that pens
through a parsed line with these widths drew the rest of it on top of 語.

The scan now returns WidthScan::{Decided(Option<f64>), Returned}: only a
subroutine `return` lets the caller continue. A subroutine that cannot be
followed (no local subrs, bad index) falls back to defaultWidthX instead of
continuing in the caller.

tests/parsed_cff_widths.rs: from_html with NotoSansJP, save, parse; every
glyph of 日本語のテキスト keeps advance 1000 (語 was 0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Arabic joins)

The PDF from from_html was always shaped correctly: azul's text3 picks the
contextual init/medi/fina forms and the lam-alef ligature, lays the run out
right to left, and the writer puts exactly those glyph ids into the content
stream with a matching ToUnicode. The SVG preview (PdfPage::to_svg, the
web demo's page view) threw that away: render_text_items_to_svg wrote each
TextItem::GlyphIds glyph back out as its Unicode text, one <text> per
glyph, and the browser shaped every character again on its own. A lone
letter has nothing to join to, so the demo showed the isolated form of
every Arabic letter at the place of its contextual form; ligatures and Indic
conjuncts fell apart the same way, and a parsed PDF's merged visual-order
TJ run was bidi-reversed a second time.

Glyph-id runs of an external font are now painted from the glyph outlines:
one <path> per (font, glyph) in a <defs> at the end of the page, one <use>
per glyph, the pen advanced through the run like a PDF viewer does (the
glyph's /W width, which merged runs are kerned against, plus Tc and the TJ
adjustment). The <text> is still written, with fill-opacity 0, so the
preview stays selectable and searchable. TextItem::Text, the builtin fonts
and faces without outlines are a painted <text> as before.
serialize::get_scaled_glyph_width is pub(crate) for the /W width.

tests/arabic_shaping.rs, with a 5 KB Vazirmatn subset (OFL, no Reserved Font
Name) whose shaping is pinned to HarfBuzz's:
- from_html gives HarfBuzz's 15 glyph ids left to right, no isolated cmap
  glyph, one lam-alef ligature, the first letter rightmost;
- the content stream carries those ids (subset off) and ToUnicode maps the
  codes back to the text (subset on and off);
- the preview paints exactly those ids at the PDF's positions, and no
  painted <text> holds Arabic (failed before: the browser drew the text);
- the preview of the saved-and-parsed PDF paints the same outlines at the
  same positions (failed before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fschutt
fschutt merged commit 195e497 into master Oct 29, 2026
49 checks passed
@fschutt
fschutt deleted the fix/arabic-shaping branch October 29, 2026 08:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant