From bc6a2bef0f730d6aa34758e3b776f50feffc4782 Mon Sep 17 00:00:00 2001 From: Dan Molitor Date: Tue, 6 Oct 2026 10:58:58 -0500 Subject: [PATCH] Justify every line through its glyph offsets; leave forced breaks alone Knuth-Plass builds justification into its positions, but when it found no solution within its tolerance and fell back to the greedy breaker, the stretch existed only as Tw. Tw never applies to registered (Type0) fonts, so those paragraphs were drawn ragged. After layout justifies a line, any gap left before the column edge is now shared out over its spaces, so a registered font is justified by its positioned TJ. Where Knuth-Plass already filled the line the gap is nil and nothing moves. On a line drawn with Tw, each styled group now starts at its own first glyph. The running cursor left out the stretch Tw gives a group's trailing space, so the next group started that much early and overlapped it ("leaders" ran into "(finance"), a regression in 0.26.0. A line that ends at a forced break is no longer justified, as CSS text-align-last specifies: the line breakers mark it with hard_break. The three new tests fail on main; the byte wall and all 15 template corpus documents are byte-identical to main. Addresses #186. Co-Authored-By: Claude Opus 5.5 --- docs/images/templates/manifest.json | 60 +++--- engine/src/layout/mod.rs | 53 ++++- engine/src/pdf/mod.rs | 9 +- engine/src/text/knuth_plass.rs | 2 + engine/src/text/mod.rs | 45 ++++- engine/tests/integration.rs | 293 ++++++++++++++++++++++++++++ 6 files changed, 423 insertions(+), 39 deletions(-) diff --git a/docs/images/templates/manifest.json b/docs/images/templates/manifest.json index f039994..355b871 100644 --- a/docs/images/templates/manifest.json +++ b/docs/images/templates/manifest.json @@ -1,122 +1,122 @@ { "invoice-standard": { - "hash": "f5694e5617647d1f3cdbbf2458e7e2f2cc877069cfdfd01675fbad4e64396d98", + "hash": "cc6a55765c1644d538f3aa1b3f46286d5db0fac002893deb7cf63e6f45482540", "pages": 1 }, "invoice-detailed": { - "hash": "74f28109070f547b30c34c2ec54bf565bb8edb28f29fff65b6343c69fee048c9", + "hash": "94d8e8743e3f8801372394926b732c3a4d536d79c6b974580ad9f65da91d9f76", "pages": 3 }, "credit-note": { - "hash": "8432372dd97363be2f7ea24c3560b016f020ae1d6505e67650fdfa81e26672c6", + "hash": "f5ab40a3fca6b4f4d63ad8d0dbcfd3ae9b0387b234db6113228d4282470d3695", "pages": 1 }, "receipt": { - "hash": "0114d709c033432ff7a5843d4e9492a675efeca43ddebc6a511836d654825e91", + "hash": "64717f9f66901b57cc601191b2f2daa37ab587b9edf3cd14d5928fbedd5ab366", "pages": 1 }, "quote": { - "hash": "ff94dd513c6f6db89c7f587a409cafac68cfa22aed960c80ebc85b744b5f9c53", + "hash": "f388ed804d3845be5c3593f4289c2269f8b5b0571dfe4612b28362f53620ffa6", "pages": 1 }, "statement": { - "hash": "a657821a722a5093f642638d713f83b0aca4ab697cead83658f0b489c6abd0d8", + "hash": "92262796e03e0ace4c4da0c647d1b2f3da6f5948dfebd6b99385d230f68532d0", "pages": 1 }, "purchase-order": { - "hash": "3a68dfe1866bdfc98f3c5237d0a002daa07068389d162ea71a293006fe93208f", + "hash": "23358bad860e87bc568b25b44b5ed3a1bfa19ff3c8925bc085c7725925331efe", "pages": 1 }, "remittance-advice": { - "hash": "dbf34a08a357b98dba4a643a57d46e043df551a493b3299ed8fdb23b95a71664", + "hash": "276ed1e6ccfce1fa876692ed2e803303ddbb860d1c734260c897b017e2d911f7", "pages": 1 }, "expense-report": { - "hash": "faf395a9bd37b46478ab05f208bc6389caf3dd1e7ecc62f7320a91c1f9f45c4b", + "hash": "d4cbe5fa9e73a8ca0b424cf46b439f2a3c820db742727370d0e231ad982acdf1", "pages": 1 }, "payslip": { - "hash": "d65d404dc18290ec41734b25515eb4043907190da4060926dbe23abed04134e6", + "hash": "ad9334a4550dee20d7716a2c5f502e79a612f5b52f3da7c2ed8e642a570caa70", "pages": 1 }, "letterhead": { - "hash": "5b7706123a933cffddbcfcc7e26a44c358fc891fb8323a5e4190a0c92c109521", + "hash": "3c2d0c1dee5d59b906034cd17e1ed6184cdd1f4c7ae133304cb89b6bc66d2303", "pages": 2 }, "cover-letter": { - "hash": "c345c63663bf5496731eb5fb6d4ce80601e2d743e5bb7d1ec2ef33577e7f532d", + "hash": "d1605c4d096e07a5c27e183cc745ce687d1c4d11687e595f83caf9a9fddfcf02", "pages": 1 }, "memo": { - "hash": "45ccbdef7e6108694f740b56fabfbb3ad298092135ac956236bca498cf2d2735", + "hash": "bfcd0b942c58cb0690a146209ecf17742ecd3aacaa4a48fab24e9ad4a0ce89a3", "pages": 1 }, "employment-contract": { - "hash": "e181a7a679d507fc4a9e7b928b1ecaac5b53e255f06d8cc790a3536448bf2678", + "hash": "d48c5a448cb4ce375af0688b55af78d0b09cfb30d1851b99ac93fef82b63a243", "pages": 3 }, "nda": { - "hash": "d0efad96a1c872f988a4096a30b88f7bf0633961ccc92773902ea44bef864da7", + "hash": "7360af507a5564213f6cc7333952f4a4436a03c783cb456d4f1f974289332daa", "pages": 2 }, "service-agreement": { - "hash": "e1ec83a4a666ff19eab9e413eda3c23ef9f3ec4f379c17f281a65ec4e374c027", + "hash": "b6ce843602ddf23f8968c3928a40339e6d0c4e7370c529902fe1a4ea45ebbeb4", "pages": 3 }, "offer-letter": { - "hash": "5fd6b37b26bef3c255fd4b83cdaf9c0aa8190d24ba72b67c2ad5949256eda76f", + "hash": "c7624a9dd566f6ca80bd2a09393f8f0a9bc732dc9fe104a220527b14a1ddce1b", "pages": 1 }, "termination-letter": { - "hash": "24ad21da616c22ed8c2fc63f02ec2d57e93b9d83d4bac742b3e1406f61195f8d", + "hash": "394fb8e1514baa2ca4436ffee3668c5dd56ced834ee2f241c19ea18bad6fd612", "pages": 1 }, "reference-letter": { - "hash": "8390dc6a76bde2701c5176ad0c5dcab90a89828f91c464c5118032d72d4d16f7", + "hash": "799905b856f8d26a0659e6764a763a861a2248efcfc5e25d1ae5fc060b2cb02e", "pages": 1 }, "policy-acknowledgement": { - "hash": "adafc101c10de077fe344d2b41b747a6985a581a372c9d3d44b7f2ff08342843", + "hash": "7b9f1599aebf599b79c15bb90cfbde2631bc9d691bfd5384a3560fe3bb353c93", "pages": 1 }, "report-annual": { - "hash": "4c88843839db90e0090ec7e16b06f08ade8bce099dd2226a6f1dc2d1b3e38a1c", + "hash": "4cdffea19119647881d5e49bd0be4c6207d504e3d104b92c086f446c737c951b", "pages": 3 }, "report-monthly": { - "hash": "36147862582563205312ba9fa1aeb2651492e00a416012dcd59c0de548085009", + "hash": "21fdf0cb1d70e64eb342b7f0ae7e50da8dc85122adcaa6b124598167047a83a1", "pages": 1 }, "lab-report": { - "hash": "38fbe98196d11e1e7536387b8398a0eec00cc265c94c5d43e74bf884f17953dd", + "hash": "213816397dcc21f6bb6d61dbd87473ec59a976721fd7e198c9e70c17bb0e7d73", "pages": 1 }, "inspection-report": { - "hash": "39e72e51dac0ca3dc9990d2c7ef2844fe7813e6923ceb6ea8642c506d4c64262", + "hash": "88bf3722555094aa60dd5f52262545ab3a76e0de5488e3801d9ecc601db25730", "pages": 1 }, "shipping-label": { - "hash": "beddef564ee4fb54263a583fac32ff9ebb373b531e500c59b5d6b055e4fcd167", + "hash": "85d8ed3ec025bc084ffc429cbe64a93fa6a5ae17fbae3781c56bd8ffb1b97862", "pages": 1 }, "packing-slip": { - "hash": "4844cb04094cf33c9b1e946d14ed2931ef15a4698c4912c5e904e27e4c59cba2", + "hash": "a7ef493010b224acf2f936885deae6e9df2b2f18558c9f04bb413c0dcbe52b12", "pages": 1 }, "delivery-note": { - "hash": "f2991fa617c5a60bed0b01deb84856b019ca5bcc448d5107e58d0af7f95adf0f", + "hash": "9b9c3d2fe2d9b50d69541840b4be74f44352e2eff9037f611a2043fcbf54324c", "pages": 1 }, "certificate": { - "hash": "5b2f629a6a4dec5d1acdad409eead2f0e8d699a00fdce795fa5bb9b82dc92469", + "hash": "cd7b8a765ec12d1cde04ae955dc0b4d98533e9f92b09d53fc7546273b4bc73df", "pages": 1 }, "product-catalog": { - "hash": "ae90c2d7d2f3f49d88f04d0442fea3c1144ae273e83b7bcee86076fd457296f3", + "hash": "3adcc4c3cd551ea6e16612b3296b17e209fa6d9135a3db1b5c067c4ff63f4acc", "pages": 2 }, "meeting-minutes": { - "hash": "309e9ed7e2916226d4fb465ae6ebe555d8bf74e3e81516ffe817ada3cdd9e10c", + "hash": "dd40860e25a6c0e4bf46732805567fe59e40c9cb3fa9960b2f23ef83fe8c0d51", "pages": 1 } } diff --git a/engine/src/layout/mod.rs b/engine/src/layout/mod.rs index 99d5490..823fb8b 100644 --- a/engine/src/layout/mod.rs +++ b/engine/src/layout/mod.rs @@ -4949,7 +4949,8 @@ impl LayoutEngine { container_start_y = cursor.content_y + cursor.y; } - let glyphs = self.build_positioned_glyphs_single_style(line, style, href, font_context); + let mut glyphs = + self.build_positioned_glyphs_single_style(line, style, href, font_context); // Use actual rendered width from glyphs for alignment (may differ from // line.width when per-char measurement is used for line breaking but @@ -4975,7 +4976,9 @@ impl LayoutEngine { // // User-set `word_spacing` is the base; when text is justified, the // computed slack-per-space is added on top. - let is_last_line = line_idx == lines.len() - 1; + // A line ending at a forced break is left alone, like the last + // line, as CSS text-align-last does (#186). + let is_last_line = line_idx == lines.len() - 1 || line.hard_break; let user_ws = style.word_spacing; let (justified_width, word_spacing) = if matches!(style.text_align, TextAlign::Justify) && !is_last_line { @@ -4990,6 +4993,9 @@ impl LayoutEngine { } else { (0.0, 0) }; + if matches!(style.text_align, TextAlign::Justify) && !is_last_line { + Self::spread_justification(&mut glyphs, text_width); + } let slack = text_width - natural_width; let ws = if space_count > 0 && slack.abs() > 0.01 { slack / space_count as f64 @@ -5087,6 +5093,39 @@ impl LayoutEngine { cursor.y += margin.bottom; } + /// Build a justified line's stretch into its glyph offsets: whatever gap + /// is left between the last non-space glyph and `width` is shared out + /// over the spaces before it, each glyph moving by the share of every + /// space to its left. Knuth-Plass already builds justification into its + /// positions, so there the gap is nil and nothing moves; when it falls + /// back to the greedy breaker, the stretch existed only as `Tw`, which a + /// registered (Type0) font never applies, and the line was drawn ragged. + /// With the stretch in the offsets, a registered font is justified by its + /// positioned `TJ`, and a styled group starts where the stretched group + /// before it ends (#186). `Tw` is unchanged: standard fonts still draw + /// their spaces with it, which now matches the offsets. + fn spread_justification(glyphs: &mut [PositionedGlyph], width: f64) { + let Some(last) = glyphs.iter().rposition(|g| g.char_value != ' ') else { + return; + }; + let spaces = glyphs[..last] + .iter() + .filter(|g| g.char_value == ' ') + .count(); + let gap = width - (glyphs[last].x_offset + glyphs[last].x_advance); + if spaces == 0 || gap.abs() <= 0.01 { + return; + } + let share = gap / spaces as f64; + let mut seen = 0usize; + for g in glyphs.iter_mut() { + g.x_offset += share * seen as f64; + if g.char_value == ' ' { + seen += 1; + } + } + } + /// The styled chars a run sequence lays out as: each run's resolved /// style, placeholders substituted and text-transform applied. Intrinsic /// measurement builds the same chars, so it measures what layout breaks. @@ -5264,7 +5303,8 @@ impl LayoutEngine { TextAlign::Justify => text_x, }; - let glyphs = self.build_positioned_glyphs_runs(run_line, font_context, style.direction); + let mut glyphs = + self.build_positioned_glyphs_runs(run_line, font_context, style.direction); // Justify: compute extra word spacing so the line fills the column width. // Use the sum of natural glyph advances (what PDF Tj actually renders) @@ -5272,7 +5312,9 @@ impl LayoutEngine { // // User-set `word_spacing` is the base; when text is justified, the // computed slack-per-space is added on top. - let is_last_line = line_idx == broken_lines.len() - 1; + // A line ending at a forced break is left alone, like the last + // line, as CSS text-align-last does (#186). + let is_last_line = line_idx == broken_lines.len() - 1 || run_line.hard_break; let user_ws = style.word_spacing; let (justified_width, word_spacing) = if matches!(style.text_align, TextAlign::Justify) && !is_last_line { @@ -5287,6 +5329,9 @@ impl LayoutEngine { } else { (0.0, 0) }; + if matches!(style.text_align, TextAlign::Justify) && !is_last_line { + Self::spread_justification(&mut glyphs, text_width); + } let slack = text_width - natural_width; let ws = if space_count > 0 && slack.abs() > 0.01 { slack / space_count as f64 diff --git a/engine/src/pdf/mod.rs b/engine/src/pdf/mod.rs index 9068ac5..c10f8a4 100644 --- a/engine/src/pdf/mod.rs +++ b/engine/src/pdf/mod.rs @@ -2045,8 +2045,13 @@ impl PdfWriter { // A registered-font group starts at its first glyph's own // position (its TJ places the rest); the running cursor // can differ from it by a kerning or justification step. - if builder.custom_font_data.contains_key(&font_key) - && !has_placeholder_group(group) + // So does every group of a line drawn with Tw: Tw also + // stretches a group's trailing space, which the running + // cursor leaves out, so the next group started that much + // early and overlapped it (#186). + if (builder.custom_font_data.contains_key(&font_key) + && !has_placeholder_group(group)) + || line.word_spacing.abs() > 0.001 { x_cursor = line.x + first.x_offset; } diff --git a/engine/src/text/knuth_plass.rs b/engine/src/text/knuth_plass.rs index 81e6ea5..d5068e6 100644 --- a/engine/src/text/knuth_plass.rs +++ b/engine/src/text/knuth_plass.rs @@ -762,6 +762,7 @@ pub fn reconstruct_lines( chars: line_chars, char_positions: line_positions, width: effective_width, + hard_break: false, }); // Next line starts after the break item @@ -975,6 +976,7 @@ pub fn reconstruct_run_lines( chars: line_chars, char_positions: line_positions, width: effective_width, + hard_break: false, }); item_start = sol.break_item + 1; diff --git a/engine/src/text/mod.rs b/engine/src/text/mod.rs index 30140e8..e322a5c 100644 --- a/engine/src/text/mod.rs +++ b/engine/src/text/mod.rs @@ -24,6 +24,9 @@ pub struct BrokenLine { pub char_positions: Vec, /// Total width of the line. pub width: f64, + /// True when the line ends at a forced break (`\n` and the like): + /// justification leaves it alone, as CSS `text-align-last` does. + pub hard_break: bool, } /// A styled character for multi-style line breaking. @@ -95,6 +98,9 @@ pub struct RunBrokenLine { pub chars: Vec, pub char_positions: Vec, pub width: f64, + /// True when the line ends at a forced break (`\n` and the like): + /// justification leaves it alone, as CSS `text-align-last` does. + pub hard_break: bool, } /// Override widths for page placeholder sentinel characters. @@ -271,6 +277,7 @@ impl TextLayout { text: String::new(), char_positions: vec![], width: 0.0, + hard_break: false, }]; } @@ -327,7 +334,9 @@ impl TextLayout { &chars[line_start..end], &char_widths[line_start..end], ); - lines.push(self.make_line(&line_chars, &line_widths)); + let mut line = self.make_line(&line_chars, &line_widths); + line.hard_break = true; + lines.push(line); line_start = i; line_width = 0.0; last_break_point = None; @@ -470,6 +479,7 @@ impl TextLayout { chars: chars.to_vec(), char_positions: positions, width: effective_width, + hard_break: false, } } @@ -820,6 +830,7 @@ impl TextLayout { chars: vec![], char_positions: vec![], width: 0.0, + hard_break: false, }]; } @@ -857,7 +868,9 @@ impl TextLayout { &chars[line_start..end], &char_widths[line_start..end], ); - lines.push(self.make_run_line(&filtered, &filtered_widths)); + let mut line = self.make_run_line(&filtered, &filtered_widths); + line.hard_break = true; + lines.push(line); line_start = i; line_width = 0.0; last_break_point = None; @@ -1072,6 +1085,7 @@ impl TextLayout { chars: chars.to_vec(), char_positions: positions, width: effective_width, + hard_break: false, } } @@ -1188,6 +1202,7 @@ impl TextLayout { text: String::new(), char_positions: vec![], width: 0.0, + hard_break: false, }]; } @@ -1239,13 +1254,16 @@ impl TextLayout { if segments.len() > 1 { // Multiple mandatory-break segments: run KP on each let mut all_lines = Vec::new(); - for seg in &segments { + // Every segment but the last ends at a forced break. + let last_seg = segments.len() - 1; + for (seg_idx, seg) in segments.iter().enumerate() { if seg.is_empty() { all_lines.push(BrokenLine { chars: vec![], text: String::new(), char_positions: vec![], width: 0.0, + hard_break: seg_idx < last_seg, }); continue; } @@ -1273,6 +1291,11 @@ impl TextLayout { justify, ); all_lines.extend(seg_lines); + if seg_idx < last_seg { + if let Some(line) = all_lines.last_mut() { + line.hard_break = true; + } + } } return all_lines; } @@ -1339,6 +1362,7 @@ impl TextLayout { chars: vec![], char_positions: vec![], width: 0.0, + hard_break: false, }]; } @@ -1397,7 +1421,14 @@ impl TextLayout { lang, justify, ); + // This segment ends at the forced break. + let produced = !seg_lines.is_empty(); all_lines.extend(seg_lines); + if produced { + if let Some(line) = all_lines.last_mut() { + line.hard_break = true; + } + } seg_start = i; } } @@ -1516,6 +1547,7 @@ impl TextLayout { text: all_chars.iter().collect(), char_positions: positions, width: total_width, + hard_break: false, }; return lines; } @@ -1557,6 +1589,7 @@ impl TextLayout { chars: truncated_chars, char_positions: positions, width: final_width, + hard_break: false, }; lines } @@ -1612,6 +1645,7 @@ impl TextLayout { text: all_chars.iter().collect(), char_positions: positions, width: total_width, + hard_break: false, }; return lines; } @@ -1642,6 +1676,7 @@ impl TextLayout { chars: truncated_chars, char_positions: positions, width, + hard_break: false, }; lines } @@ -1681,6 +1716,7 @@ impl TextLayout { chars: all_chars, char_positions: positions, width: total_width, + hard_break: false, }; return lines; } @@ -1741,6 +1777,7 @@ impl TextLayout { chars: truncated, char_positions: positions, width: x + ellipsis_width, + hard_break: false, }; lines } @@ -1779,6 +1816,7 @@ impl TextLayout { chars: all_chars, char_positions: positions, width: total_width, + hard_break: false, }; return lines; } @@ -1808,6 +1846,7 @@ impl TextLayout { chars: truncated, char_positions: positions, width, + hard_break: false, }; lines } diff --git a/engine/tests/integration.rs b/engine/tests/integration.rs index dd83c5a..edc9c4a 100644 --- a/engine/tests/integration.rs +++ b/engine/tests/integration.rs @@ -15770,6 +15770,299 @@ fn test_registered_font_justification_is_drawn_to_the_edge() { } } +// ─── #186: justified text when Knuth-Plass falls back ──────────────── + +/// The reporter's paragraph: at 10pt in 300pt, Knuth-Plass finds no +/// solution within its tolerance and the greedy breaker sets the lines. +const JUSTIFY_186: &str = "Coordination of transformation programmes for executive committees: scoping, roadmap, governance of use cases and change management across several hundred people, with a monthly review of value and risks."; + +/// `content` justified in a 300pt column, as plain text or as two runs. +fn justified_300(content: &str, family: &str, runs: bool) -> Node { + let mut text = make_text(content, 10.0); + if runs { + let split = content.find(' ').unwrap() + 1; + text.kind = NodeKind::Text { + content: String::new(), + href: None, + runs: vec![ + TextRun { + content: content[..split].to_string(), + style: Style::default(), + href: None, + }, + TextRun { + content: content[split..].to_string(), + style: Style::default(), + href: None, + }, + ], + }; + } + text.style.font_family = Some(family.to_string()); + text.style.text_align = Some(TextAlign::Justify); + Node { + kind: NodeKind::View, + style: Style { + width: Some(Dimension::Pt(300.0)), + ..Default::default() + }, + children: vec![text], + id: None, + source_location: None, + bookmark: None, + href: None, + alt: None, + } +} + +/// Knuth-Plass builds justification into its positions, so registered fonts +/// were justified only when it found a solution. When it fell back to the +/// greedy breaker, justification existed only as `Tw`, which does not apply +/// to Type0 fonts, and the paragraph was drawn ragged (#186). The column's +/// right edge is the page margin (54) plus 300. +#[test] +fn test_justified_registered_font_reaches_the_edge_when_knuth_plass_falls_back() { + for runs in [false, true] { + let lines = liberation_lines(justified_300(JUSTIFY_186, "Liberation", runs)); + assert!(lines.len() >= 3, "a multi-line paragraph"); + for (_, right, _) in &lines[..lines.len() - 1] { + assert!( + (right - 354.0).abs() < 0.05, + "laid out to {right:.2}pt of 354pt (runs: {runs})" + ); + } + // Drawn where laid out. The replay counts every glyph's advance, so + // compare with the laid-out pen end, trailing space included: a + // greedy line keeps the space it broke after, past the edge, inkless. + let mut drawn = drawn_line_ends(&liberation_pdf(justified_300( + JUSTIFY_186, + "Liberation", + runs, + ))); + drawn.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap()); + assert_eq!(drawn.len(), lines.len()); + for ((_, end), (x, _, glyphs)) in drawn.iter().zip(&lines) { + let laid_out = glyphs + .iter() + .map(|g| x + g.x_offset + g.x_advance) + .fold(f64::MIN, f64::max); + assert!( + (end - laid_out).abs() < 0.05, + "drawn to {end:.2}pt, laid out to {laid_out:.2}pt (runs: {runs})" + ); + } + } +} + +/// Where each styled group of a standard-font paragraph starts and where +/// its pen ends when drawn: replays the stream's Td and the Tw in effect +/// against the layout's own advances (exact `/Widths` for standard fonts) +/// and letter spacing, one entry per group, line by line. +fn standard_font_group_spans(doc: &Document) -> Vec> { + let pages = LayoutEngine::new().layout(doc, &FontContext::new()); + fn lines_of(els: &[forme::layout::LayoutElement], out: &mut Vec) { + for el in els { + if let forme::layout::DrawCommand::Text { lines, .. } = &el.draw { + out.extend(lines.iter().cloned()); + } + lines_of(&el.children, out); + } + } + let mut lines = Vec::new(); + lines_of(&pages[0].elements, &mut lines); + // The writer's groups: consecutive glyphs sharing font and color. + let key = |g: &forme::layout::PositionedGlyph| { + format!( + "{}|{}|{:?}|{:?}", + g.font_family, g.font_weight, g.font_style, g.color + ) + }; + let groups: Vec>> = lines + .iter() + .map(|l| { + let mut gs: Vec> = Vec::new(); + for g in &l.glyphs { + match gs.last_mut() { + Some(v) if key(v.last().unwrap()) == key(g) => v.push(g.clone()), + _ => gs.push(vec![g.clone()]), + } + } + gs + }) + .collect(); + // Per text object: each group's (start x, Tw in effect). Tw is text + // state, so it carries across text objects. + let stream = decompress_pdf_streams(&render_to_pdf(doc)); + let (mut tw, mut x) = (0.0_f64, 0.0_f64); + let mut starts: Vec> = Vec::new(); + for t in stream.lines().map(str::trim) { + if t == "BT" { + starts.push(Vec::new()); + x = 0.0; + } else if let Some(v) = t.strip_suffix(" Tw") { + tw = v.trim().parse().unwrap(); + } else if let Some(v) = t.strip_suffix(" Td") { + x += v.split_whitespace().next().unwrap().parse::().unwrap(); + starts.last_mut().unwrap().push((x, tw)); + } + } + assert_eq!(starts.len(), lines.len(), "one text object per line"); + groups + .iter() + .zip(&starts) + .map(|(line_groups, line_starts)| { + assert_eq!(line_starts.len(), line_groups.len(), "one Td per group"); + line_groups + .iter() + .zip(line_starts) + .map(|(g, &(start, tw))| { + let ls = g[0].letter_spacing; + let advance: f64 = g.iter().map(|p| p.x_advance + ls).sum(); + let spaces = g.iter().filter(|p| p.char_value == ' ').count() as f64; + (start, start + advance + tw * spaces) + }) + .collect() + }) + .collect() +} + +/// A justified line made of several styled runs is drawn group by group, +/// each stretched by `Tw`. The next group started where the previous one's +/// unjustified glyphs ended, so the stretched group overlapped it: +/// "leaders" ran into "(finance" (#186, a regression in 0.26.0). +#[test] +fn test_justified_styled_runs_do_not_overlap() { + let runs = [ + ("Strategy advisory", 700, FontStyle::Normal), + (" for senior leaders ", 400, FontStyle::Normal), + ("(finance, operations, technology)", 400, FontStyle::Italic), + ( + " and business units: direct hiring ", + 400, + FontStyle::Normal, + ), + ( + "(junior to executive profiles in analytics)", + 400, + FontStyle::Italic, + ), + ( + " and structuring of analytics departments.", + 400, + FontStyle::Normal, + ), + ]; + let mut text = make_text("", 10.0); + text.kind = NodeKind::Text { + content: String::new(), + href: None, + runs: runs + .iter() + .map(|(c, w, st)| TextRun { + content: c.to_string(), + style: Style { + font_weight: Some(*w), + font_style: Some(*st), + ..Default::default() + }, + href: None, + }) + .collect(), + }; + text.style.text_align = Some(TextAlign::Justify); + let doc = default_doc(vec![Node { + kind: NodeKind::View, + style: Style { + width: Some(Dimension::Pt(300.0)), + ..Default::default() + }, + children: vec![text], + id: None, + source_location: None, + bookmark: None, + href: None, + alt: None, + }]); + let spans = standard_font_group_spans(&doc); + assert!(spans.len() >= 3, "a multi-line paragraph"); + for (i, line) in spans.iter().enumerate() { + for pair in line.windows(2) { + let (_, end) = pair[0]; + let (next, _) = pair[1]; + assert!( + (end - next).abs() < 0.05, + "line {i}: a group drawn to {end:.2}pt but the next starts at {next:.2}pt" + ); + } + } +} + +/// Every laid-out text line of `node`, with Liberation Sans registered. +fn text_lines_with_liberation(node: Node) -> Vec { + let font = std::fs::read("../packages/fonts-standard/fonts/LiberationSans-Regular.ttf") + .expect("Liberation Sans in the repo"); + let mut font_context = FontContext::new(); + font_context + .registry_mut() + .register("Liberation", 400, false, font); + let pages = LayoutEngine::new().layout(&default_doc(vec![node]), &font_context); + fn walk(els: &[forme::layout::LayoutElement], out: &mut Vec) { + for el in els { + if let forme::layout::DrawCommand::Text { lines, .. } = &el.draw { + out.extend(lines.iter().cloned()); + } + walk(&el.children, out); + } + } + let mut out = Vec::new(); + walk(&pages[0].elements, &mut out); + out +} + +/// A line that ends with a forced break is not justified, as in CSS +/// (`text-align-last` applies to it). It was stretched to the full width +/// through `Tw` (#186). The wrapped lines after the break are justified. +#[test] +fn test_justified_line_before_a_forced_break_is_not_stretched() { + let content = "Team: up to three juniors and one senior\nResults: about forty projects per year with a steady margin and a quarterly review."; + for runs in [false, true] { + for family in ["Helvetica", "Liberation"] { + let lines = text_lines_with_liberation(justified_300(content, family, runs)); + assert!( + lines.len() >= 3, + "{family}: break, then a wrapped paragraph" + ); + let text = |l: &forme::layout::TextLine| -> String { + l.glyphs + .iter() + .map(|g| g.char_value) + .collect::() + .trim_end() + .to_string() + }; + let right = |l: &forme::layout::TextLine| { + l.glyphs + .iter() + .filter(|g| g.char_value != ' ') + .map(|g| l.x + g.x_offset + g.x_advance) + .fold(f64::MIN, f64::max) + }; + assert_eq!(text(&lines[0]), "Team: up to three juniors and one senior"); + assert!( + lines[0].word_spacing.abs() < 0.001 && right(&lines[0]) < 300.0, + "{family} (runs: {runs}): the line before the break is stretched (Tw {:.2}, ends at {:.2}pt)", + lines[0].word_spacing, + right(&lines[0]) + ); + assert!( + (right(&lines[1]) - 354.0).abs() < 0.05, + "{family} (runs: {runs}): the wrapped line after the break is justified, got {:.2}pt", + right(&lines[1]) + ); + } + } +} + /// A line with nothing to adjust keeps a plain `Tj`. #[test] fn test_registered_font_text_without_adjustments_keeps_tj() {