diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f2def2fd6..b55eab943 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -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> 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> 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}"); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 32d63d5c8..f19a5da47 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -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, } fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { @@ -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, Option, Result, String>); + /// A `Do` met while interpreting: its operator index, XObject name and the state there. type Invocation = (usize, Vec, Ts); @@ -737,12 +748,12 @@ fn interpret( doc: &Document, ops: &[Op], fonts_res: &Dict, - cache: &mut HashMap, (Rc, Option)>, + cache: &mut HashMap, CachedFont>, carry: &mut Carry, mut invoked: Option<&mut Vec>, // `Some(properties)`: read `/ActualText`, looking named property lists up in `properties`. logical_text: Option<&Dict>, -) -> Vec { +) -> Result, EditError> { let mut out: Vec = 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, EditErro let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::>()); 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, 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, 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, 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, EditError let pieces = Pieces::join(&streams.into_iter().map(|(_, data)| data).collect::>()); 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::>()); 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, 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 { @@ -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::>()); 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.