M7: justify paragraphs whose font shows the space with another code (#1832)

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 <noreply@anthropic.com>
This commit is contained in:
komo1iddin
2026-10-15 02:25:53 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent b18639c242
commit 6aa9de485c
3 changed files with 110 additions and 12 deletions
+76
View File
@@ -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::<String>();
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> <ffff> 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");
+28 -12
View File
@@ -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));
}
+6
View File
@@ -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.