fix: read the writing mode leniently and join vertical glyph runs into one line

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-18 23:38:35 +02:00
co-authored by Claude Haiku 5.5
parent 20cfb67dd1
commit ed47f71374
2 changed files with 105 additions and 15 deletions
+63 -8
View File
@@ -4979,9 +4979,15 @@ fn flattening_comments_keeps_annotations_that_are_not_comments() {
/// A page of three vertical (`Identity-V`) columns at 20 pt: `ABCDEF` at x = 500, the same
/// text 30 pt to its left, and `ABCD` far away. Synthetic font: no font program is needed.
fn vertical_columns_page() -> Document {
let mut doc = text_page(
vertical_page(
"BT /V1 20 Tf 500 720 Td <004100420043004400450046> Tj ET\nBT /V1 20 Tf 470 720 Td <004100420043004400450046> Tj ET\nBT /V1 20 Tf 300 720 Td <0041004200430044> Tj ET",
);
)
}
/// A page drawing `content` with the vertical font `/V1` (as in [`vertical_columns_page`]) and
/// Helvetica `/F1`.
fn vertical_page(content: &str) -> Document {
let mut doc = text_page(content);
let mut font = Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("Type0")),
@@ -5001,7 +5007,13 @@ fn vertical_columns_page() -> Document {
]))]),
);
let p = pdfcraft_model::pages(&doc)[0].obj;
let fonts = Dict::from_iter([(b"V1".to_vec(), Object::Dict(font))]);
let helvetica = Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("Type1")),
(b"BaseFont".to_vec(), Object::name("Helvetica")),
(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")),
]);
let fonts = Dict::from_iter([(b"V1".to_vec(), Object::Dict(font)), (b"F1".to_vec(), Object::Dict(helvetica))]);
let resources = Dict::from_iter([(b"Font".to_vec(), Object::Dict(fonts))]);
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
doc
@@ -5036,10 +5048,8 @@ fn vertical_text_lines_are_columns_and_editing_one_keeps_its_neighbour() {
}
}
#[test]
fn text_whose_cmap_writing_mode_cannot_be_read_is_not_edited() {
let mut doc = vertical_columns_page();
// An embedded CMap that uses itself: which way its glyphs advance can't be known.
/// Gives `/V1` an embedded CMap that uses itself: which way its glyphs advance can't be known.
fn make_cmap_cyclic(doc: &mut Document) {
let cmap = doc.add(Object::Null);
doc.set(
cmap,
@@ -5048,7 +5058,7 @@ fn text_whose_cmap_writing_mode_cannot_be_read_is_not_edited() {
b"1 begincodespacerange <0000> <ffff> endcodespacerange",
)),
);
let p = pdfcraft_model::pages(&doc)[0].obj;
let p = pdfcraft_model::pages(doc)[0].obj;
doc.update_dict(p, |d| {
if let Some(Object::Dict(res)) = d.get_mut(b"Resources")
&& let Some(Object::Dict(fonts)) = res.get_mut(b"Font")
@@ -5058,6 +5068,51 @@ fn text_whose_cmap_writing_mode_cannot_be_read_is_not_edited() {
}
})
.unwrap();
}
#[test]
fn text_whose_cmap_writing_mode_cannot_be_read_is_not_edited() {
let mut doc = vertical_columns_page();
make_cmap_cyclic(&mut doc);
let error = text::replace_line(&mut doc, 0, 0, "ABCDEA").unwrap_err();
assert!(error.to_string().contains("cyclic"), "{error}");
}
#[test]
fn a_cyclic_cmap_falls_back_to_horizontal_and_only_refuses_its_own_lines() {
let mut doc = vertical_page("BT /V1 20 Tf 100 700 Td <00410042> Tj ET\nBT /F1 12 Tf 100 600 Td (Plain) Tj ET");
make_cmap_cyclic(&mut doc);
// The page is still listed: the broken font's text as horizontal, the rest untouched.
let lines = text::text_lines(&doc, 0).unwrap();
let texts: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
assert_eq!(texts, ["AB", "Plain"], "{texts:?}");
assert!(lines[0].rect[2] - lines[0].rect[0] > 30.0 && lines[0].rect[3] - lines[0].rect[1] < 30.0, "{:?}", lines[0].rect);
assert_eq!(text::reading_blocks(&doc, 0).unwrap().len(), 2);
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2);
// Only the line in the broken font is refused.
let error = text::replace_line(&mut doc.clone(), 0, 0, "BA").unwrap_err();
assert!(error.to_string().contains("cyclic"), "{error}");
let block = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "AB").unwrap();
let error = text::replace_block(&mut doc.clone(), 0, block, "BA").unwrap_err();
assert!(error.to_string().contains("cyclic"), "{error}");
text::replace_line(&mut doc, 0, 1, "Changed").unwrap();
let texts: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(texts, ["AB", "Changed"]);
}
#[test]
fn vertical_text_drawn_one_glyph_per_tj_is_one_line() {
// One text object, a `Tj` per glyph; and a column with a text object per glyph.
let doc = vertical_page(
"BT /V1 20 Tf 500 720 Td <0041> Tj <0042> Tj <0043> Tj ET\n\
BT /V1 20 Tf 1 0 0 1 470 720 Tm <0041> Tj ET\nBT /V1 20 Tf 1 0 0 1 470 700 Tm <0042> Tj ET\nBT /V1 20 Tf 1 0 0 1 470 680 Tm <0043> Tj ET",
);
let lines = text::text_lines(&doc, 0).unwrap();
let columns: Vec<(f64, &str)> = lines.iter().map(|l| (l.baseline()[0], l.text.as_str())).collect();
assert_eq!(columns, [(500.0, "ABC"), (470.0, "ABC")], "{columns:?}");
for line in &lines {
let r = line.rect;
assert!((r[1] - 660.0).abs() < 0.5 && (r[3] - 720.0).abs() < 0.5, "{r:?}");
}
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2);
}
+42 -7
View File
@@ -68,9 +68,17 @@ pub struct TextLine {
/// Some of the line's text is `/ActualText`, which is already in logical order: visual-order
/// Indic recovery would scramble it.
actual_text: bool,
/// Why the line can't be edited: its font's writing mode (its CMap's `WMode`) couldn't be read,
/// so which way its glyphs advance is unknown. The line is listed as horizontal text.
unreadable_mode: Option<String>,
}
impl TextLine {
/// Refuses editing a line whose font's writing mode couldn't be read (§9.7.4.3).
fn ensure_editable(&self) -> Result<(), EditError> {
self.unreadable_mode.as_ref().map_or(Ok(()), |why| Err(EditError::Invalid(why.clone())))
}
/// Origin of the source baseline in PDF user space, independent of font ascent/descent.
pub fn baseline(&self) -> [f64; 2] {
[self.origin.x, self.origin.baseline]
@@ -522,6 +530,9 @@ struct Ts {
/// The font's vertical advance per unit of font size (`/DW2`'s w1, normally −1) when its
/// CMap writes vertically (`WMode 1`, §9.7.4.3): each glyph then moves the pen down, not right.
vertical: Option<f64>,
/// Why the font's writing mode couldn't be read (a broken or cyclic CMap). Its text is then
/// listed as horizontal, but the lines in it are refused for editing.
unreadable: Option<Rc<str>>,
}
fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
@@ -670,9 +681,15 @@ struct Shown {
impl Shown {
/// The unit direction of its baseline, where it starts and ends along it, and the baseline's
/// offset across it (for upright text: the user-space x, x and y).
/// offset across it (for upright text: the user-space x, x and y). A vertical run goes along
/// its column instead, the way the pen moves: for upright text down, with the column's x
/// across.
fn span(&self) -> ((f64, f64), f64, f64, f64) {
let dir = direction(&self.tm.then(&self.state.ctm));
let m = self.tm.then(&self.state.ctm);
let dir = match self.state.vertical {
Some(w1) => direction(&Matrix([m.0[2] * w1.signum(), m.0[3] * w1.signum(), 0.0, 0.0, 0.0, 0.0])),
None => direction(&m),
};
let (start, across) = to_frame(dir, (self.start_x, self.baseline));
(dir, start, to_frame(dir, (self.end_x, self.end_y)).0, across)
}
@@ -702,8 +719,8 @@ impl Pen {
let (dir, start, _, across) = s.span();
let gap = start - self.end;
let same_dir = (dir.0 - self.dir.0).abs() < 1e-3 && (dir.1 - self.dir.1).abs() < 1e-3;
// The geometry above is along a horizontal baseline: vertical runs never continue one.
let same_dir = same_dir && !self.vertical && s.state.vertical.is_none();
// A column continues a column (along which the pen moves), a baseline a baseline.
let same_dir = same_dir && self.vertical == s.state.vertical.is_some();
(same_dir && (across - self.across).abs() < self.size * 0.3 && gap > -self.size && gap < self.size * reach, gap)
}
}
@@ -732,6 +749,7 @@ impl Carry {
rise: 0.0,
render_mode: 0,
vertical: None,
unreadable: None,
};
Carry { ts, stack: Vec::new(), marked: Vec::new() }
}
@@ -848,8 +866,12 @@ fn interpret(
});
ts.font = Some((name.to_vec(), m.clone()));
ts.face = face.clone();
// Which way the glyphs advance is unknown: refuse rather than guess (§9.7.4.3).
ts.vertical = vertical.clone().map_err(EditError::Invalid)?;
// Which way the glyphs advance is unknown (§9.7.4.3): list the font's text as
// horizontal and record why, so editing its lines is refused (not the page).
match vertical {
Ok(v) => (ts.vertical, ts.unreadable) = (*v, None),
Err(e) => (ts.vertical, ts.unreadable) = (None, Some(Rc::from(e.as_str()))),
}
}
}
b"Tc" => ts.char_spacing = op.num(0).unwrap_or(0.0),
@@ -1129,6 +1151,9 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
l.decodable &= s.decodable;
l.visual_order |= s.visual_order;
l.actual_text |= s.actual_text;
if l.unreadable_mode.is_none() {
l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned);
}
// Spaces may use a separate subset font; they do not change the visible style.
} else {
lines.push(TextLine {
@@ -1149,6 +1174,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
scope: s.scope,
logical_order: s.logical_order,
actual_text: s.actual_text,
unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned),
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
origin: Origin {
tm: s.tm.0,
@@ -1219,6 +1245,9 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
l.decodable &= s.decodable;
l.visual_order |= s.visual_order;
l.actual_text |= s.actual_text;
if l.unreadable_mode.is_none() {
l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned);
}
} else {
lines.push(TextLine {
text: s.text.clone(),
@@ -1238,6 +1267,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
scope: s.scope,
logical_order: s.logical_order,
actual_text: s.actual_text,
unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned),
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
origin: Origin {
tm: s.tm.0,
@@ -1806,6 +1836,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
let text = text.replace(['\n', '\r'], " ");
let lines = text_lines(doc, page)?;
let target = lines.get(line).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no line {}", page + 1, line + 1)))?;
target.ensure_editable()?;
let p = page_dict(doc, page)?;
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
@@ -1961,6 +1992,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
let blocks = group_blocks(&lines);
let target = blocks.get(block).ok_or_else(|| EditError::Invalid("the paragraph no longer exists".into()))?;
let targets: Vec<&TextLine> = target.lines.iter().filter_map(|i| lines.get(*i)).collect();
targets.iter().try_for_each(|line| line.ensure_editable())?;
let mut drops = coincident_ops(&lines, &targets);
drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied()));
let p = page_dict(doc, page)?;
@@ -2126,6 +2158,7 @@ fn rewrite_block_from(
let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?;
let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect();
let first = members.first().copied().ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?;
members.iter().try_for_each(|line| line.ensure_editable())?;
let p = page_dict(doc, page)?;
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
@@ -2556,7 +2589,8 @@ pub(crate) fn selectable_blocks(
/// The vertical advance per unit of font size of a font that writes vertically: `/DW2`'s w1
/// (default −1000, §9.7.4.3), or `None` for horizontal text. Per-glyph `/W2` entries are not
/// read. An encoding whose writing mode can't be read is an error, as in [`vertical_encoding`].
/// read. An encoding whose writing mode can't be read is an error, as in [`vertical_encoding`];
/// listing text treats it as horizontal and records the error (see [`Ts::unreadable`]).
fn vertical_advance(doc: &Document, font: &Dict) -> Result<Option<f64>, EditError> {
if !vertical_encoding(doc, font)? {
return Ok(None);
@@ -2636,6 +2670,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize
let b = blocks.get(*index).ok_or_else(|| EditError::Invalid("a selected paragraph no longer exists".into()))?;
for line in &b.lines {
let l = lines.get(*line).ok_or_else(|| EditError::Invalid("a selected text line no longer exists".into()))?;
l.ensure_editable()?;
selected.extend(l.ops.iter().copied());
}
}