Lay out vertical (Identity-V) text as columns in the edit layer

Fixes #3216

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-18 23:31:50 +02:00
co-authored by Claude Haiku 5.5
parent dab3149110
commit 20cfb67dd1
2 changed files with 194 additions and 29 deletions
+86
View File
@@ -4975,3 +4975,89 @@ fn flattening_comments_keeps_annotations_that_are_not_comments() {
let without_widget: Vec<&str> = all.iter().copied().filter(|s| *s != "Widget").collect();
assert_eq!(left(&reopen(&doc)), without_widget, "flattening fields touches no annotation that isn't a widget");
}
/// 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(
"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",
);
let mut 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-V")),
]);
let unicode = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"1 begincodespacerange <0000> <ffff> endcodespacerange 6 beginbfchar <0041> <0041> <0042> <0042> <0043> <0043> <0044> <0044> <0045> <0045> <0046> <0046> endbfchar",
)));
font.set(b"ToUnicode".to_vec(), Object::Ref(unicode));
font.set(
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(1000)),
]))]),
);
let p = pdfcraft_model::pages(&doc)[0].obj;
let fonts = Dict::from_iter([(b"V1".to_vec(), Object::Dict(font))]);
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
}
#[test]
fn vertical_text_lines_are_columns_and_editing_one_keeps_its_neighbour() {
let doc = vertical_columns_page();
let lines = text::text_lines(&doc, 0).unwrap();
let first = lines.iter().find(|l| l.baseline()[0] == 500.0).unwrap();
let r = first.rect;
// Six glyphs, each 20 pt wide and advancing 20 pt down, centred on x = 500.
assert!((r[0] - 490.0).abs() < 0.5 && (r[2] - 510.0).abs() < 0.5, "{r:?}");
assert!((r[1] - 600.0).abs() < 0.5 && (r[3] - 720.0).abs() < 0.5, "{r:?}");
for edit in [0, 1] {
let mut doc = vertical_columns_page();
let lines = text::text_lines(&doc, 0).unwrap();
if edit == 0 {
let line = lines.iter().position(|l| l.baseline()[0] == 500.0).unwrap();
text::replace_line(&mut doc, 0, line, "ABCDEA").unwrap();
} else {
let blocks = text::text_blocks(&doc, 0).unwrap();
let block = blocks.iter().position(|b| b.lines.iter().any(|&i| lines[i].baseline()[0] == 500.0)).unwrap();
assert_eq!(blocks[block].lines.len(), 1, "{blocks:?}");
text::replace_block(&mut doc, 0, block, "ABCDEA").unwrap();
}
let after = text::text_lines(&doc, 0).unwrap();
let texts: Vec<(f64, &str)> = after.iter().map(|l| (l.baseline()[0], l.text.as_str())).collect();
assert!(texts.contains(&(500.0, "ABCDEA")), "{texts:?}");
assert!(texts.contains(&(470.0, "ABCDEF")), "{texts:?}");
assert!(texts.contains(&(300.0, "ABCD")), "{texts:?}");
}
}
#[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.
let cmap = doc.add(Object::Null);
doc.set(
cmap,
Object::Stream(Stream::flate(
Dict::from_iter([(b"UseCMap".to_vec(), Object::Ref(cmap))]),
b"1 begincodespacerange <0000> <ffff> endcodespacerange",
)),
);
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")
&& let Some(Object::Dict(font)) = fonts.get_mut(b"V1")
{
font.set(b"Encoding".to_vec(), Object::Ref(cmap));
}
})
.unwrap();
let error = text::replace_line(&mut doc, 0, 0, "ABCDEA").unwrap_err();
assert!(error.to_string().contains("cyclic"), "{error}");
}
+108 -29
View File
@@ -519,6 +519,9 @@ struct Ts {
leading: f64,
rise: f64,
render_mode: i64,
/// 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>,
}
fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
@@ -684,12 +687,13 @@ struct Pen {
across: f64,
end: f64,
size: f64,
vertical: bool,
}
impl Pen {
fn after(s: &Shown) -> Self {
let (dir, _, end, across) = s.span();
Pen { bt: s.bt, dir, across, end, size: s.size.max(1.0) }
Pen { bt: s.bt, dir, across, end, size: s.size.max(1.0), vertical: s.state.vertical.is_some() }
}
/// Whether `s` could continue the same line on geometry alone: on the same baseline, starting
@@ -698,6 +702,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();
(same_dir && (across - self.across).abs() < self.size * 0.3 && gap > -self.size && gap < self.size * reach, gap)
}
}
@@ -725,11 +731,16 @@ impl Carry {
leading: 0.0,
rise: 0.0,
render_mode: 0,
vertical: None,
};
Carry { ts, stack: Vec::new(), marked: Vec::new() }
}
}
/// A font resource's metrics, fallback face and vertical advance (see [`Ts`]), read once per page.
/// The advance is an error message when the font's writing mode can't be read.
type CachedFont = (Rc<Metrics>, Option<String>, Result<Option<f64>, String>);
/// A `Do` met while interpreting: its operator index, XObject name and the state there.
type Invocation = (usize, Vec<u8>, Ts);
@@ -737,12 +748,12 @@ fn interpret(
doc: &Document,
ops: &[Op],
fonts_res: &Dict,
cache: &mut HashMap<Vec<u8>, (Rc<Metrics>, Option<String>)>,
cache: &mut HashMap<Vec<u8>, CachedFont>,
carry: &mut Carry,
mut invoked: Option<&mut Vec<Invocation>>,
// `Some(properties)`: read `/ActualText`, looking named property lists up in `properties`.
logical_text: Option<&Dict>,
) -> Vec<Shown> {
) -> Result<Vec<Shown>, EditError> {
let mut out: Vec<Shown> = Vec::new();
let mut ts = carry.ts.clone();
let mut at_bt = (0usize, ts.clone());
@@ -828,14 +839,17 @@ fn interpret(
b"Tf" => {
ts.size = op.num(1).unwrap_or(ts.size);
if let Some(name) = op.name(0) {
let (m, face) = cache.entry(name.to_vec()).or_insert_with(|| {
let (m, face, vertical) = cache.entry(name.to_vec()).or_insert_with(|| {
let dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned());
let metrics = dict.as_ref().map_or_else(Metrics::fallback, |d| Metrics::from_dict(doc, d));
let vertical = dict.as_ref().map_or(Ok(None), |d| vertical_advance(doc, d).map_err(|e| e.to_string()));
let face = dict.filter(is_type3_fallback).map(|_| metrics.base_font.clone());
(Rc::new(metrics), face)
(Rc::new(metrics), face, vertical)
});
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)?;
}
}
b"Tc" => ts.char_spacing = op.num(0).unwrap_or(0.0),
@@ -888,6 +902,10 @@ fn interpret(
let mut text = String::new();
let mut decodable = true;
let mut x_text = 0.0;
// Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred
// on it horizontally; `half` is half the widest glyph.
let vertical = ts.vertical;
let (mut y_text, mut half) = (0.0, 0.0_f64);
// Where each word's glyphs start and end along the run (text space), and whether
// the next glyph continues the last word.
let mut words: Vec<(f64, f64)> = Vec::new();
@@ -905,6 +923,21 @@ fn interpret(
Some(t) => text.push_str(&t),
None => decodable = false,
}
let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
if let Some(w1) = vertical {
half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0);
if !blank {
let ink_end = y_text + w1 * ts.size;
let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end));
match words.last_mut() {
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
_ => words.push((lo, hi)),
}
}
in_word = !blank;
y_text += w1 * ts.size + spacing;
continue;
}
if !blank {
let ink_end = x_text + m.width(code) * ts.size * ts.scale;
// A negative font size or `Tz` runs the glyphs backwards:
@@ -922,6 +955,18 @@ fn interpret(
}
other => {
if let Some(n) = other.as_f64() {
if vertical.is_some() {
// Down by n/1000 em, not scaled by `Tz`.
let dy = -n / 1000.0 * ts.size;
if -dy > ts.size * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
if dy.abs() > ts.size.abs() * 0.2 {
in_word = false;
}
y_text += dy;
continue;
}
let dx = -n / 1000.0 * ts.size * ts.scale;
// A large gap inside TJ reads as a space.
if dx > ts.size * 0.2 && !text.ends_with(' ') {
@@ -937,15 +982,22 @@ fn interpret(
}
}
}
tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, 0.0]).then(&tm);
tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(&tm);
let end = tm.then(&ts.ctm).apply(0.0, ts.rise);
// The box: from descent to ascent along the run.
let corners = [
trm0.apply(0.0, ts.rise + m.descent * ts.size),
trm0.apply(x_text, ts.rise + m.descent * ts.size),
trm0.apply(0.0, ts.rise + m.ascent * ts.size),
trm0.apply(x_text, ts.rise + m.ascent * ts.size),
];
// The box from `a` to `b` along the run: from descent to ascent across it, or for
// vertical text, the widest glyph's width centred on the pen.
let span = |a: f64, b: f64| match vertical {
Some(_) => {
[trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)]
}
None => [
trm0.apply(a, ts.rise + m.descent * ts.size),
trm0.apply(b, ts.rise + m.descent * ts.size),
trm0.apply(a, ts.rise + m.ascent * ts.size),
trm0.apply(b, ts.rise + m.ascent * ts.size),
],
};
let corners = span(0.0, if vertical.is_some() { y_text } else { x_text });
let rect = corners
.iter()
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
@@ -959,8 +1011,7 @@ fn interpret(
let ink = words
.iter()
.map(|&(a, b)| {
let (y0, y1) = (ts.rise + m.descent * ts.size, ts.rise + m.ascent * ts.size);
[trm0.apply(a, y0), trm0.apply(b, y0), trm0.apply(a, y1), trm0.apply(b, y1)]
span(a, b)
.iter()
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)])
})
@@ -975,8 +1026,17 @@ fn interpret(
op: i,
extra_ops: Vec::new(),
glyph_count,
advance: x_text,
tm: Matrix([tm.0[0], tm.0[1], tm.0[2], tm.0[3], tm.0[4] - x_text * tm.0[0], tm.0[5] - x_text * tm.0[1]]),
// Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the
// preview divides by size × `Tz`, so `Tz` is put back here.
advance: if vertical.is_some() { y_text * ts.scale } else { x_text },
tm: Matrix([
tm.0[0],
tm.0[1],
tm.0[2],
tm.0[3],
tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2],
tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3],
]),
state: ts.clone(),
tlm,
bt_op: at_bt.0,
@@ -1009,7 +1069,7 @@ fn interpret(
carry.ts = ts;
carry.stack = stack;
carry.marked = marked;
out
Ok(out)
}
/// The lines of text on a page (0-based), in content order.
@@ -1025,7 +1085,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default();
let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, Some(&properties));
let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, Some(&properties))?;
let mut last: Option<(usize, Pen)> = None; // the stream piece and where its last run ended
for mut s in shown {
// A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a
@@ -1264,18 +1324,18 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
visits: usize,
}
/// `stream` gives the stream number of the operator at an index of `ops`.
fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) {
fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) -> Result<(), EditError> {
let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let mut cache = HashMap::new();
let mut invoked = Vec::new();
let properties = resources.get(b"Properties").and_then(|o| w.doc.dict(o)).unwrap_or_default();
let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), Some(&properties));
let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), Some(&properties))?;
let mut last = None;
let mut last_stream = None;
let mut forms = invoked.into_iter().peekable();
for s in shown {
while let Some((_, name, ts)) = forms.next_if(|f| f.0 < s.op) {
enter(w, resources, &name, ts);
enter(w, resources, &name, ts)?;
// Text after a form starts a new line.
last = None;
}
@@ -1287,11 +1347,12 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
add_shown(&mut w.lines, &mut last, s, si);
}
for (_, name, ts) in forms {
enter(w, resources, &name, ts);
enter(w, resources, &name, ts)?;
}
Ok(())
}
fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts) {
let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.visits) else { return };
fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts) -> Result<(), EditError> {
let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.visits) else { return Ok(()) };
let ops = parse(&form.data).ops;
// The form starts with the graphics state at its `Do`, its own matrix applied.
let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new() };
@@ -1300,10 +1361,11 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
if let Some(r) = form.obj {
w.path.push(r);
}
walk(w, &ops, &form.resources, &mut carry, &|_| stream);
walk(w, &ops, &form.resources, &mut carry, &|_| stream)?;
if form.obj.is_some() {
w.path.pop();
}
Ok(())
}
let p = page_dict(doc, page)?;
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
@@ -1317,7 +1379,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
let pieces = Pieces::join(&streams.into_iter().map(|(_, data)| data).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let stream_of = |i: usize| ops.get(i).map_or(0, |op| pieces.pieces_of(op).0);
walk(&mut w, &ops, &res, &mut carry, &stream_of);
walk(&mut w, &ops, &res, &mut carry, &stream_of)?;
let mut lines = w.lines;
// A visual-order Indic font draws pre-base vowel signs before their consonant.
for l in lines.iter_mut().filter(|l| l.visual_order && !l.actual_text) {
@@ -1907,7 +1969,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
let streams = content_streams(doc, &p.dict);
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None);
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None)?;
let mut patches = HashMap::new();
for s in shown.into_iter().filter(|s| drops.contains(&s.op)) {
if s.state.render_mode >= 4 {
@@ -2492,6 +2554,23 @@ pub(crate) fn selectable_blocks(
.collect())
}
/// 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`].
fn vertical_advance(doc: &Document, font: &Dict) -> Result<Option<f64>, EditError> {
if !vertical_encoding(doc, font)? {
return Ok(None);
}
let descendant = font.get(b"DescendantFonts").and_then(|d| doc.resolve(d).as_array().and_then(|a| a.first().cloned()));
let w1 = descendant
.and_then(|d| doc.resolve(&d).as_dict().and_then(|d| d.get(b"DW2").cloned()))
.and_then(|a| doc.resolve(&a).as_array().and_then(|a| a.get(1).cloned()))
.and_then(|w| doc.resolve(&w).as_f64())
.filter(|w| w.is_finite())
.unwrap_or(-1000.0);
Ok(Some(w1 / 1000.0))
}
/// Detect vertical CMaps before moving horizontal text. A broken/cyclic embedded CMap is
/// refused rather than guessing which direction TJ advances its text matrix.
fn vertical_encoding(doc: &Document, font: &Dict) -> Result<bool, EditError> {
@@ -2564,7 +2643,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize
let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None);
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None)?;
let mut patches = HashMap::new();
// Keep full precision: a tiny rounded text-space change can be magnified by a CTM,
// and restoring rise/spacing must not alter following unselected text.