mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude Haiku 5.5
parent
20cfb67dd1
commit
ed47f71374
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user