From 6aa9de485c1ac8a70763e6b45b3e5c2da792c9c8 Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:25:53 +0500 Subject: [PATCH] M7: justify paragraphs whose font shows the space with another code (#1832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Word spacing reaches only single-byte code 32 (ISO 32000-1 §9.3.3). Justify was therefore turned off for a composite source font, so the paragraph came out left-aligned. And the generated Type 3 fallback, whose codes are numbered from 1, had its 32nd character widened instead of its spaces. Where word spacing doesn't reach the space, each space of a stretched row is now followed by a TJ adjustment of the same amount, and word spacing stays 0. Fonts whose space is code 32 keep using Tw as before. Co-authored-by: Claude Opus 5.5 --- crates/edit/src/tests.rs | 76 +++++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 40 ++++++++++++------ docs/editing-existing-text.md | 6 +++ 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 9f99349b8..839ba00ee 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2171,6 +2171,33 @@ fn hebrew_edits_are_drawn_right_to_left_and_keep_their_right_edge() { assert!((line.rect[0] - before[0]).abs() < 0.5, "left-aligned: {:?} vs {before:?}", line.rect); } +/// A generated Type 3 font numbers its glyphs from 1, so code 32 is a character, not the space: +/// justified rows are stretched after each space, never with word spacing. +#[test] +fn justified_text_in_the_type3_fallback_stretches_its_spaces() { + let src = "BT /F1 12 Tf 14 TL 100 700 Td (Some words to fill a row of text) Tj T* (and another one below it) Tj T* (end) Tj ET"; + let mut doc = text_page(src); + let rect = text::text_blocks(&doc, 0).unwrap()[0].rect; + // More than 32 different characters, so one of them gets code 32. + let typed = "日本語の 文章を 書きます 東京 大阪 京都 名古屋 札幌 福岡 神戸 横浜 仙台 広島 千葉 新潟 静岡 熊本 岡山 金沢"; + let style = text::BlockStyle { align: Some(added::Align::Justify), ..Default::default() }; + let rewritten = text::rewrite_block(&mut doc, 0, 0, Some(typed), &style); + if pdfcraft_fonts::document_japanese_font().is_none() { + assert!(rewritten.is_err()); + eprintln!("built without a craft-fonts Japanese face: the Type 3 path is checked with CRAFT_FONTS_DIR"); + return; + } + rewritten.unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.len() > 2, "{lines:?}"); + for l in &lines[..lines.len() - 1] { + assert!((l.rect[2] - rect[2]).abs() < 1.0, "{:?} spans {:?}, not {rect:?}", l.text, l.rect); + } + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + assert!(ops.iter().any(|o| o.is("TJ")), "{ops:?}"); + assert!(ops.iter().filter(|o| o.is("Tw")).all(|o| o.operands.first().and_then(|n| n.as_f64()) == Some(0.0)), "{ops:?}"); +} + /// Letters the page's font lacks are drawn with the Hebrew face from craft-fonts; a build /// without it says so and changes nothing. Hebrew points are refused, not misdrawn. #[test] @@ -2735,6 +2762,55 @@ fn group_moves_keep_split_stream_tokens_and_shared_form_resources() { assert_eq!(page_content_bytes(&doc, 1), source_page); } +#[test] +fn justified_text_in_a_composite_font_fills_each_row_with_adjustments_after_its_spaces() { + // Two-byte codes, so word spacing (single-byte code 32 only) can't stretch the spaces. + let hex = |s: &str| s.bytes().map(|b| format!("00{b:02x}")).collect::(); + let src = format!( + "BT /F1 10 Tf 12 TL 100 700 Td <{}> Tj T* <{}> Tj T* <{}> Tj ET", + hex("One two three four five six seven"), + hex("eight nine ten eleven twelve"), + hex("and then the end of it") + ); + let mut doc = text_page(&src); + let unicode = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"1 begincodespacerange <0000> endcodespacerange 1 beginbfrange <0020> <007e> <0020> endbfrange", + ))); + let font = Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type0")), + (b"BaseFont".to_vec(), Object::name("SyntheticCID")), + (b"Encoding".to_vec(), Object::name("Identity-H")), + (b"ToUnicode".to_vec(), Object::Ref(unicode)), + ( + b"DescendantFonts".to_vec(), + Object::Array(vec![Object::Dict(Dict::from_iter([ + (b"Subtype".to_vec(), Object::name("CIDFontType2")), + (b"DW".to_vec(), Object::Int(500)), + ]))]), + ), + ]); + let p = pdfcraft_model::pages(&doc)[0].obj; + let resources = Dict::from_iter([(b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"F1".to_vec(), Object::Dict(font))])))]); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + let before = text::text_blocks(&doc, 0).unwrap()[0].clone(); + let right = before.rect[2]; + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { align: Some(added::Align::Justify), ..Default::default() }).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.len() > 2, "{lines:?}"); + for l in &lines[..lines.len() - 1] { + assert!((l.rect[2] - right).abs() < 1.0, "{:?} ends at {} not {right}", l.text, l.rect[2]); + } + // The source font shows the text, with its spaces, and the text reads as before, rewrapped. + let flat = |s: &str| s.replace('\n', " "); + assert_eq!(flat(&text::text_blocks(&doc, 0).unwrap()[0].editor_text), flat(&before.editor_text)); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + assert!(ops.iter().all(|o| !o.is("Tf") || o.operands.first().and_then(|n| n.as_name()) == Some(b"F1".as_slice())), "{ops:?}"); + assert!(ops.iter().any(|o| o.is("TJ")), "{ops:?}"); + assert!(ops.iter().filter(|o| o.is("Tw")).all(|o| o.operands.first().and_then(|n| n.as_f64()) == Some(0.0)), "{ops:?}"); +} + #[test] fn group_moves_preserve_encoded_glyphs_and_refuse_vertical_cmaps() { let mut doc = text_page("BT /F1 12 Tf 40 400 Td <004100420043> Tj ET"); diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 8fa394194..bf310156d 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1946,9 +1946,15 @@ fn rewrite_block_from( _ => 0.0, } }; - // Justify: word spacing (single-byte code 32 only) so each line but the last fills the width. - let single_byte = !reuse || metrics.as_ref().is_some_and(|m| !m.composite); - let justify = align == Some(crate::added::Align::Justify) && single_byte; + // Justify: each line but the last fills the width. Word spacing reaches only single-byte code 32 + // (ISO 32000-1 §9.3.3), so where a space is shown with another code (a composite font, a + // generated Type 3 font, a subset without a space) the extra space follows each space as a + // TJ adjustment instead, and code 32, which may then be a letter, gets none. + let justify = align == Some(crate::added::Align::Justify); + let word_spacing_reaches_spaces = match metrics.as_ref().filter(|_| reuse) { + Some(m) => m.encode(" ").is_some_and(|b| m.codes(&b).iter().all(|(c, len)| m.is_space(*c, *len))), + None => type3.is_none(), + }; let mut x = 0.0; let mut tw_set = 0.0; let mut underlines: Vec<(f64, f64, f64)> = Vec::new(); // (x0, x1, y) in text space @@ -1962,24 +1968,34 @@ fn rewrite_block_from( block_ops.push(Op::new("Td", vec![n(dx - x), n(if i > 0 { -lead } else { 0.0 })])); } x = dx; + let stretch = if word_spacing_reaches_spaces { 0.0 } else { tw }; + let tw = tw - stretch; if tw != tw_set { block_ops.push(Op::new("Tw", vec![n(tw)])); tw_set = tw; } // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). let visual = crate::bidi::to_visual(line); - if let Some(metrics) = metrics.as_ref().filter(|_| reuse) { - let indic_line = if metrics.visual_order { crate::indic::to_visual(&visual) } else { visual }; - block_ops.extend(source_show(metrics, &indic_line, size, space, o_state.char_spacing, tw)?); - } else { - for (font, bytes) in encode(&visual).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { - if font != shown_font { - block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)])); - shown_font = font; + // Stretched without word spacing: shown up to and including each space, then moved on. + let pieces = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] }; + for piece in pieces { + if let Some(metrics) = metrics.as_ref().filter(|_| reuse) { + let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() }; + block_ops.extend(source_show(metrics, &indic_piece, size, space, o_state.char_spacing, tw)?); + } else { + for (font, bytes) in encode(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { + if font != shown_font { + block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)])); + shown_font = font; + } + block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); } - block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); + } + if stretch > 0.0 && piece.ends_with(' ') { + block_ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / size)])])); } } + let tw = tw + stretch; let w = (advance(line) + tw * spaces as f64 * o_state.scale * k) / k; underlines.push((dx, dx + w, -(i as f64) * lead - size * 0.12)); } diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 233e5ac59..50b9261c9 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -50,6 +50,12 @@ a second bidi reversal; adjacent visual glyph runs still recover their own readi Only right-to-left ActualText is kept apart this way: left-to-right spans, such as the Latin ligatures (`fi`, `fl`) Word and Chrome wrap in ActualText mid-word, join their line as before. +A justified paragraph stretches each row but its last to the paragraph's width by widening its +spaces. Word spacing (`Tw`) widens only single-byte code 32, so where the font shows a space with +another code (a composite font, the generated Type 3 fallback) each space is followed by a `TJ` +adjustment instead. Justifying a paragraph with several formats in it is not supported yet: it is +left-aligned. + For headless editing, use `text_font_files` → `text_font_import` → `text_fonts` and pass a resource to `text_edit.font_resource`. `text_edit.ranges` holds selected range formatting. `text_edit_layout` accepts resource/ranges too and measures a draft without applying it.