Skip to content

textAlign: justify — ignored with registered TTF fonts; mixed runs overlap; lines ending with a forced break are stretched (0.27.0) #186

Closed

Description

@jarvis-ai-ia

Version: @formepdf/core / @formepdf/react 0.27.0 (Node 24.21.0, macOS). Downstream: Reactive Resume 6.0.0 renders documents with Forme 0.27, so justified text is affected there too.

textAlign: "justify" has three problems (minimal reproduction below, Lorem ipsum text):

  1. Registered (embedded) TTF fonts are never justified. The same paragraph is justified with the built-in Helvetica but stays ragged-right with a registered TTF (tested with Arimo 400 from Google Fonts), whatever lineBreaking or hyphens. Possibly related to Type0 / Identity-H fonts, where Tw has no effect (ISO 32000-1, 9.3.3).
  2. Mixed runs collide when justified (built-in Helvetica). With bold / italic child <Text> runs inside a justified paragraph, the run that follows a styled run starts too early and touches or overlaps it. A single-style paragraph is fine.
  3. A line ending with a forced break (\n) is stretched. Such lines should not be justified (CSS applies text-align-last to them), but Forme spreads their words across the full width.

Expected: justified lines with any font, no collision between runs, no stretching of lines that end with a forced break.

Reproduction (Arimo.ttf = static Arimo 400 from fonts.gstatic.com):

import { createElement as h } from "react";
import { Document, Page, View, Text, Font } from "@formepdf/react";
import { renderDocument } from "@formepdf/core";
import { readFileSync, writeFileSync } from "node:fs";
// Arimo 400 static TTF from Google Fonts (any registered TTF behaves the same)
Font.register({ family: "Arimo", src: new Uint8Array(readFileSync("Arimo.ttf")), fontWeight: 400 });
const P = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua, ut enim ad minim veniam.";
const box = (label, style, kids) => h(View, { style: { marginBottom: 14, width: 300 } },
  h(Text, { style: { fontSize: 8, color: "#c00" } }, label),
  h(Text, { style: { fontSize: 10, textAlign: "justify", ...style } }, ...kids));
const B = (t) => h(Text, { style: { fontWeight: 700 } }, t), I = (t) => h(Text, { style: { fontStyle: "italic" } }, t);
const doc = h(Document, null, h(Page, { size: "A4", margin: 40 },
  box("1. Helvetica (built-in): justified OK", {}, [P]),
  box("2. Registered TTF (Arimo): NOT justified", { fontFamily: "Arimo" }, [P]),
  box("3. Helvetica, mixed bold/italic runs: runs overlap", {}, [B("Lorem ipsum"), " dolor sit amet ", I("(consectetur adipiscing elit)"), " sed do eiusmod tempor ", I("(incididunt ut labore et dolore)"), " magna aliqua ut enim ad minim veniam."]),
  box("4. Helvetica, line ending with a forced break is stretched", {}, ["Lorem ipsum dolor sit amet\nConsectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."]),
));
writeFileSync("repro2.pdf", await renderDocument(doc)); console.log("ok");

Boxes 2, 3 and 4 of the resulting PDF show the three problems; box 1 is the reference. Thank you for Forme!

Activity

  1. danmolitor commented on Oct 6, 2026

    @danmolitor
    Owner

    Thanks for the clear reproduction, all three reproduced and are fixed in #, which ships in the next release:

    1. Registered fonts were justified only when the optimal line breaker found a solution. When it fell back to the greedy breaker (as in your paragraph), justification existed only as PDF word spacing (Tw), which doesn't apply to embedded TrueType fonts. Justification now goes into the glyph positions on every line.
    2. The overlap was a regression in 0.26.0: each styled run started without the extra spacing of the run before it. Fixed.
    3. A line ending with a forced break is no longer stretched, matching CSS.

    Your five-box reproduction now renders all of them justified, with no overlap and the forced-break line left alone.

  2. self-assigned this
    on Oct 6, 2026
  3. danmolitor commented on Oct 6, 2026

    @danmolitor
    Owner

    Shipped in 0.28.0 (now on npm, PyPI and crates.io). See the 0.28.0 entry in CHANGELOG.md.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions