mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-26 19:19:10 -06:00
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:
co-authored by
Claude Opus 5.5
parent
b18639c242
commit
6aa9de485c
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user