Files
pdfcraft/crates/edit/src/text/interp.rs
T

891 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::fallback::is_type3_fallback;
use super::rewrite::vertical_advance;
use super::{Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, Rc};
/// Text and graphics state captured for rewriting.
#[derive(Clone, Debug, PartialEq)]
pub(super) struct TextState {
pub(crate) font: Option<(String, f64)>,
pub(crate) char_spacing: f64,
pub(crate) word_spacing: f64,
pub(crate) scale: f64,
pub(super) leading: f64,
pub(crate) rise: f64,
pub(crate) fill: Vec<Op>,
/// The text rendering mode (`Tr`); 3 is invisible text, as in an OCR layer over a scan.
pub(crate) render_mode: i64,
/// The stroke colour operators, the line width and the ExtGStates selected (see [`Ts`]):
/// graphics state the text was painted with that [`TextState::ops`] doesn't restate.
pub(super) stroke: Vec<Op>,
pub(super) line_width: Option<f64>,
ext_gstate: Vec<Vec<u8>>,
/// The dash pattern and line cap in force (see [`Ts`]): compared, not restated.
pub(super) dash: Dash,
pub(super) line_cap: i64,
}
impl TextState {
pub(super) fn of(ts: &Ts) -> Self {
TextState {
font: ts.font.as_ref().map(|(n, _)| (String::from_utf8_lossy(n).into_owned(), ts.size)),
char_spacing: ts.char_spacing,
word_spacing: ts.word_spacing,
scale: ts.scale,
leading: ts.leading,
rise: ts.rise,
fill: ts.fill.clone(),
render_mode: ts.render_mode,
stroke: ts.stroke.clone(),
line_width: ts.line_width,
ext_gstate: ts.ext_gstate.clone(),
dash: ts.dash.clone(),
line_cap: ts.line_cap,
}
}
/// Operators that change how text is painted from `current` (the state where they run) to
/// this one: the ExtGStates, stroke colour, line width and a visible rendering mode. A
/// rewritten paragraph is drawn before its text object, so whatever that object set before
/// its first show (`/GS1 gs`, `1 Tr 1 0 0 RG 2 w` inside `BT`, #3508/#3509) must be stated
/// again. Empty when nothing differs.
///
/// Only fill, stroke and fill-then-stroke (`Tr` 0–2) are restated: invisible text is revealed
/// by [`reveal_ops`] and clipping text is refused, so neither is ever drawn this way.
///
/// A `gs` has no inverse, so a selection `current` has and this state doesn't stays in force:
/// run these inside a `q`/`Q` that starts from `current`, one per state drawn.
pub(crate) fn paint_ops(&self, current: &TextState) -> Vec<Op> {
let n = pdfcraft_content::num;
let mut v = Vec::new();
// The selections both share stay in force; the ones after them are selected again in order.
let shared = self.ext_gstate.iter().zip(&current.ext_gstate).take_while(|(a, b)| a == b).count();
for name in self.ext_gstate.iter().skip(shared) {
v.push(Op::new("gs", vec![Object::Name(name.clone())]));
}
// A reselected ExtGState may set `/LW`, so the width (already the effective one) follows it.
let reselected = !v.is_empty();
if self.stroke != current.stroke {
if self.stroke.is_empty() {
// The initial stroke colour: black in DeviceGray.
v.push(Op::new("G", vec![n(0.0)]));
} else {
v.extend(self.stroke.iter().cloned());
}
}
let width = self.line_width.unwrap_or(1.0);
if reselected || width != current.line_width.unwrap_or(1.0) {
v.push(Op::new("w", vec![n(width)]));
}
if (0..=2).contains(&self.render_mode) && self.render_mode != current.render_mode {
v.push(Op::new("Tr", vec![Object::Int(self.render_mode)]));
}
v
}
/// Operators that set this state (inside a text object).
pub(crate) fn ops(&self) -> Vec<Op> {
let n = pdfcraft_content::num;
let mut v = Vec::new();
if let Some((f, size)) = &self.font {
v.push(Op::new("Tf", vec![Object::name(f), n(*size)]));
}
v.push(Op::new("Tc", vec![n(self.char_spacing)]));
v.push(Op::new("Tw", vec![n(self.word_spacing)]));
v.push(Op::new("Tz", vec![n(self.scale * 100.0)]));
v.push(Op::new("TL", vec![n(self.leading)]));
v.push(Op::new("Ts", vec![n(self.rise)]));
v.extend(self.fill.iter().cloned());
v
}
}
/// What a run is drawn in beyond its text state: the optional content groups of the `/OC`
/// marked-content sequences around it, and the effective graphics state parameters (`gs`) in
/// force. A rewrite writes a paragraph in its first run's place, so runs that differ here are
/// never joined: one in a hidden layer or at another opacity would lose it.
#[derive(Clone, Debug, Default, PartialEq)]
pub(super) struct Paint {
layers: Vec<Vec<u8>>,
gs: Gs,
}
/// The transparency parameters of the graphics state: what the `gs` operators have set, resolved,
/// so a repeated `gs` or two names with the same settings leave it as it was.
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Gs {
/// `ca`, the fill opacity.
fill_alpha: f64,
/// `CA`, the stroke opacity.
stroke_alpha: f64,
/// `BM`, the blend mode (`Compatible` is `Normal`).
blend: Vec<u8>,
/// `SMask`, when it is not `None`: the reference of an indirect soft mask, else the dictionary.
soft_mask: Option<Object>,
}
impl Default for Gs {
fn default() -> Self {
Gs { fill_alpha: 1.0, stroke_alpha: 1.0, blend: b"Normal".to_vec(), soft_mask: None }
}
}
impl Gs {
/// Applies the parameters a graphics state parameter dictionary sets; the others stay.
fn apply(&mut self, doc: &Document, params: &Dict) {
let num = |key: &[u8]| params.get(key).and_then(|o| doc.resolve(o).as_f64()).filter(|v| v.is_finite());
if let Some(v) = num(b"ca") {
self.fill_alpha = v;
}
if let Some(v) = num(b"CA") {
self.stroke_alpha = v;
}
if let Some(bm) = params.get(b"BM").map(|o| doc.resolve(o)) {
let first = bm
.as_name()
.map(<[u8]>::to_vec)
.or_else(|| bm.as_array().and_then(|a| a.iter().find_map(|o| doc.resolve(o).as_name().map(<[u8]>::to_vec))));
if let Some(name) = first {
self.blend = if name == b"Compatible" { b"Normal".to_vec() } else { name };
}
}
if let Some(mask) = params.get(b"SMask") {
self.soft_mask = match (mask.as_ref(), &*doc.resolve(mask)) {
(_, Object::Name(_)) => None,
(Some(r), _) => Some(Object::Ref(r)),
(None, Object::Dict(d)) => Some(Object::Dict(d.clone())),
(None, _) => None,
};
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Origin {
pub(crate) tm: [f64; 6],
/// The text line matrix there (where the next line's Td/T* starts from).
pub(crate) tlm: [f64; 6],
/// Text-space units → user space (the scale of the text rendering matrix's y axis).
pub(crate) k: f64,
/// The CTM there (current space → page space), for moving the paragraph in page space.
pub(crate) ctm: [f64; 6],
pub(crate) baseline: f64,
pub(crate) x: f64,
pub(crate) state: TextState,
pub(crate) bt_op: usize,
pub(crate) bt_state: TextState,
}
/// The unit x axis of a text rendering matrix in user space (`(1, 0)` when it is degenerate).
pub(super) fn direction(m: &Matrix) -> (f64, f64) {
let (x, y) = (m.0[0], m.0[1]);
let length = x.hypot(y);
if length.is_finite() && length > 1e-9 { (x / length, y / length) } else { (1.0, 0.0) }
}
/// User-space point `p` as `(along, across)` a baseline running in unit direction `dir`. Upright
/// text (`dir` = `(1, 0)`) keeps its coordinates, so lines and rows of turned text are found the
/// same way (#1443).
pub(super) fn to_frame(dir: (f64, f64), p: (f64, f64)) -> (f64, f64) {
(p.0 * dir.0 + p.1 * dir.1, p.1 * dir.0 - p.0 * dir.1)
}
/// The box covering both `[x0, y0, x1, y1]` boxes.
pub(crate) fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] {
[a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]
}
#[derive(Clone)]
pub(super) struct Ts {
pub(super) ctm: Matrix,
/// The operators that set the current fill colour (`g`, `rg`, `k`, or `cs` + `sc`/`scn`).
pub(super) fill: Vec<Op>,
pub(super) font: Option<(Vec<u8>, Rc<Metrics>)>,
/// The Japanese or Hebrew fallback face the font subsets, when the font is one (see [`is_type3_fallback`]).
pub(super) face: Option<String>,
pub(super) size: f64,
pub(super) char_spacing: f64,
pub(super) word_spacing: f64,
pub(super) scale: f64,
pub(super) leading: f64,
pub(super) rise: f64,
pub(super) render_mode: i64,
/// The transparency parameters the `gs` operators have set so far (see [`Paint`]).
pub(super) gs: Gs,
/// 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.
pub(super) 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.
pub(super) unreadable: Option<Rc<str>>,
/// The operators that set the current stroke colour (`G`, `RG`, `K`, or `CS` + `SC`/`SCN`).
pub(super) stroke: Vec<Op>,
/// The line width a `w` or an ExtGState's `/LW` set (`None`: the initial 1.0).
pub(super) line_width: Option<f64>,
/// The ExtGState resources selected with `gs`, oldest first (see [`select_gs`]).
pub(super) ext_gstate: Vec<Vec<u8>>,
/// The dash pattern a `d` or an ExtGState's `/D` set (initially solid: `[] 0`).
pub(super) dash: Dash,
/// The line cap a `J` or an ExtGState's `/LC` set (initially 0, butt).
pub(super) line_cap: i64,
}
/// A dash pattern (ISO 32000-2 §8.4.3.6): the dash array and the phase.
type Dash = (Vec<f64>, f64);
/// The most dash array entries a pattern keeps; a longer one is ignored, as is a malformed one.
const MAX_DASH: usize = 32;
/// The dash pattern `array phase`, as `d` takes it and an ExtGState's `/D` holds it.
fn dash_pattern(doc: &Document, array: Option<&Object>, phase: Option<&Object>) -> Option<Dash> {
let array = doc.resolve(array?);
let items = array.as_array().filter(|a| a.len() <= MAX_DASH)?;
let finite = |o: &Object| doc.resolve(o).as_f64().filter(|v| v.is_finite());
Some((items.iter().map(finite).collect::<Option<Vec<_>>>()?, finite(phase?)?))
}
/// A line cap style, as `J` takes it and an ExtGState's `/LC` holds it: 0, 1 or 2.
fn line_cap(doc: &Document, cap: Option<&Object>) -> Option<i64> {
doc.resolve(cap?).as_int().filter(|c| (0..=2).contains(c))
}
/// The most `gs` selections a state remembers. Repeating a selection moves it to the end, so
/// this bounds the distinct ExtGStates in force, not the operators a hostile stream repeats.
const MAX_GS: usize = 32;
/// Record `gs /name` in `list`. A `gs` sets every parameter its dictionary names to an absolute
/// value, so a later selection of the same dictionary overrides the earlier one completely: the
/// earlier entry goes, which keeps replaying the list equivalent to replaying the stream.
fn select_gs(list: &mut Vec<Vec<u8>>, name: &[u8]) {
list.retain(|n| n != name);
if list.len() >= MAX_GS {
list.remove(0);
}
list.push(name.to_vec());
}
/// The text-showing operators of one stream with their text, font, box and baseline.
pub(super) struct Shown {
pub(super) op: usize,
pub(super) extra_ops: Vec<usize>,
/// Encoded glyphs, not bytes or Unicode characters (a ligature can represent several).
pub(super) glyph_count: usize,
/// Advance in text space, including spacing and TJ adjustments.
pub(super) advance: f64,
pub(super) tracking: f64,
/// The text matrix where it starts (text space), and the state there and at its `BT`.
pub(super) tm: Matrix,
pub(super) tlm: Matrix,
pub(super) state: Ts,
pub(super) bt_op: usize,
pub(super) bt_state: Ts,
pub(super) text: String,
pub(super) rect: [f64; 4],
/// Its box along and across its baseline (see [`TextLine::frame`]).
pub(super) frame: [f64; 4],
/// The boxes of its words (see [`TextLine::ink`]).
pub(super) ink: Vec<[f64; 4]>,
pub(super) baseline: f64,
pub(super) start_x: f64,
end_x: f64,
end_y: f64,
pub(super) bt: usize,
pub(super) font: Vec<u8>,
pub(super) base_font: String,
pub(super) face: Option<String>,
pub(super) size: f64,
pub(super) bold: bool,
pub(super) italic: bool,
pub(super) visual_order: bool,
pub(super) decodable: bool,
pub(super) scope: Option<usize>,
pub(super) paint: Paint,
pub(super) logical_order: bool,
pub(super) actual_text: bool,
}
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). 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 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)
}
}
/// Where the previous run of a line ended: its text object, baseline direction, offset across
/// the baseline, end along it and size.
#[derive(Clone, Copy)]
pub(super) struct Pen {
pub(super) bt: usize,
dir: (f64, f64),
across: f64,
end: f64,
pub(super) size: f64,
vertical: bool,
}
impl Pen {
pub(super) fn after(s: &Shown) -> Self {
let (dir, _, end, across) = s.span();
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
/// at most a word gap (`reach` sizes) after it. Also the gap along the baseline.
pub(super) fn continues(&self, s: &Shown, reach: f64) -> (bool, f64) {
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;
// 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)
}
}
/// Use the lower third of letter gaps, not word gaps. A pair of isolated labels is not
/// evidence of tracking; unadjusted neighbours inside a string contribute zero too.
/// The tracking a run of glyphs was set with: the lower third of its gaps, when that is at
/// most half an em (`limit`). A wider "tracking" is word or cell spacing between one-glyph
/// words (`a b c`, a column of digits or Y/N ticks), which must keep its spaces.
fn typical_tracking(gaps: &mut [f64], limit: f64) -> f64 {
if gaps.len() < 3 {
return 0.0;
}
let at = gaps.len() / 3;
gaps.select_nth_unstable_by(at, f64::total_cmp);
gaps.get(at).copied().filter(|n| n.is_finite() && *n <= limit).unwrap_or(0.0).max(0.0)
}
fn tj_tracking(pieces: &[Object], metrics: &Metrics, size: f64, scale: f64) -> f64 {
let mut gaps = Vec::new();
let mut previous = false;
let mut gap = 0.0;
for piece in pieces {
if let Object::String(s) = piece {
for (code, len) in metrics.codes(&s.bytes) {
let blank = metrics.is_space(code, len) || metrics.text_of(code).is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
if previous && !blank {
gaps.push(gap);
}
previous = !blank;
gap = 0.0;
}
} else if let Some(n) = piece.as_f64() {
gap += -n / 1000.0 * size * scale;
}
}
typical_tracking(&mut gaps, size * scale.abs() * 0.5)
}
fn positioned_tracking(shown: &mut [Shown]) {
let mut start = 0;
while start < shown.len() {
let mut end = start.saturating_add(1);
while let (Some(prev), Some(next)) = (shown.get(end.saturating_sub(1)), shown.get(end)) {
if prev.glyph_count != 1
|| next.glyph_count != 1
|| prev.font != next.font
|| prev.scope != next.scope
|| (prev.size - next.size).abs() > 0.01
|| prev.state.char_spacing != next.state.char_spacing
|| prev.state.scale != next.state.scale
|| !Pen::after(prev).continues(next, 1.5).0
{
break;
}
end = end.saturating_add(1);
}
if let Some(run) = shown.get_mut(start..end) {
let mut gaps: Vec<f64> = run
.windows(2)
.filter_map(|pair| {
let [prev, next] = pair else { return None };
(!prev.text.trim().is_empty() && !next.text.trim().is_empty()).then(|| Pen::after(prev).continues(next, 0.0).1)
})
.collect();
let limit = run.first().map_or(0.0, |s| s.size * s.state.scale.abs() * 0.5);
let tracking = typical_tracking(&mut gaps, limit);
for s in run.iter_mut().skip(1) {
s.tracking = tracking;
}
}
start = end;
}
}
/// The graphics state carried from one of a page's content streams to the next: the streams
/// are one stream in pieces (§7.8.2), so a `cm` (AutoCAD scales the whole page in the first
/// stream), an unbalanced `q`, the font and the colour still apply in the streams after it.
pub(super) struct Carry {
pub(super) ts: Ts,
pub(super) stack: Vec<Ts>,
pub(super) marked: Vec<Option<usize>>,
/// For each open marked-content sequence, its optional content group when it is `/OC`.
pub(super) layers: Vec<Option<Vec<u8>>>,
/// Bytes of glyph text the rest of the page may still produce (see [`MAX_PAGE_TEXT`]).
pub(super) text_left: usize,
}
/// The most glyph text one page's extraction produces, in bytes. A ToUnicode destination is
/// copied for every glyph showing its code, so a small page can otherwise expand into gigabytes;
/// real pages hold far less (a whole novel is about 1 MB).
const MAX_PAGE_TEXT: usize = 16 << 20;
impl Carry {
pub(super) fn new() -> Self {
let ts = Ts {
ctm: Matrix::IDENTITY,
fill: Vec::new(),
font: None,
face: None,
size: 0.0,
char_spacing: 0.0,
word_spacing: 0.0,
scale: 1.0,
leading: 0.0,
rise: 0.0,
render_mode: 0,
gs: Gs::default(),
vertical: None,
unreadable: None,
stroke: Vec::new(),
line_width: None,
ext_gstate: Vec::new(),
dash: (Vec::new(), 0.0),
line_cap: 0,
};
Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT }
}
}
/// 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);
#[derive(Default)]
struct MarkedContent {
regions: Vec<Option<usize>>,
layers: Vec<Option<Vec<u8>>>,
actual_text: Vec<Option<(usize, usize, String)>>,
}
impl MarkedContent {
fn begin(&mut self, doc: &Document, op: &Op, index: usize, logical_text: Option<&Dict>, shown_count: usize) {
// Marked text paragraphs/headings are authoritative boundaries. Ignore artifacts and
// inner spans, which inherit their enclosing region.
let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH"));
self.regions.push(if region { Some(index) } else { self.regions.last().copied().flatten() });
// A layer named in the resources is told apart by its name, an inline one by where it opens.
let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice());
self.layers
.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec)));
// The property list is inline or named in the resources' /Properties (§14.6.2).
let list = logical_text.and_then(|properties| match op.operands.get(1) {
Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)),
other => other.and_then(Object::as_dict).cloned(),
});
let logical = list.as_ref().and_then(|dict| dict.get(b"ActualText")).and_then(|value| match value {
Object::String(value) if value.bytes.len() <= 4_000_000 => Some(value.to_text()),
_ => None,
});
self.actual_text.push(logical.map(|text| (shown_count, index, text)));
}
fn end(&mut self, index: usize, shown: &mut Vec<Shown>) {
self.regions.pop();
self.layers.pop();
if let Some(Some((start, open, logical))) = self.actual_text.pop() {
let tail = shown.split_off(start.min(shown.len()));
let mut items = tail.into_iter();
if let Some(mut first) = items.next() {
for item in items {
first.glyph_count = first.glyph_count.saturating_add(item.glyph_count);
first.extra_ops.push(item.op);
first.extra_ops.extend(item.extra_ops);
first.rect = [
first.rect[0].min(item.rect[0]),
first.rect[1].min(item.rect[1]),
first.rect[2].max(item.rect[2]),
first.rect[3].max(item.rect[3]),
];
first.frame = union(first.frame, item.frame);
first.ink.extend(item.ink);
}
first.extra_ops.extend([open, index]);
// Only right-to-left ActualText is already in typing order and must stay out of
// bidi recovery. Word and Chrome also wrap Latin ligatures mid-word.
first.logical_order = logical.chars().any(crate::bidi::is_rtl);
first.actual_text = true;
first.text = logical;
first.start_x = first.rect[0];
first.end_x = first.rect[2];
first.end_y = first.baseline;
first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6);
shown.push(first);
}
}
}
}
struct TextRunState<'a> {
ts: &'a mut Ts,
tm: &'a mut Matrix,
tlm: &'a mut Matrix,
at_bt: &'a (usize, Ts),
bt: usize,
marked: &'a MarkedContent,
carry: &'a mut Carry,
out: &'a mut Vec<Shown>,
}
fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> {
let ts = &mut *state.ts;
let tm = &mut *state.tm;
let tlm = &mut *state.tlm;
let at_bt = state.at_bt;
let bt = state.bt;
let marked = state.marked;
let carry = &mut *state.carry;
let out = &mut *state.out;
if matches!(op.op.as_slice(), b"'" | b"\"") {
if op.is("\"") {
ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing);
ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing);
}
*tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(tlm);
*tm = *tlm;
}
let Some((name, m)) = ts.font.clone() else { return Ok(()) };
// Pieces: strings, and TJ number adjustments.
let pieces: Vec<Object> = match op.op.as_slice() {
// A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still
// takes the one immediately before it, as the renderer and every other
// reader here do (#3569) -- not the first, which a reader counting operands
// from the front of a surplus list would otherwise take.
b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(),
_ => op.operands.last().cloned().into_iter().collect(),
};
// Tc is already included in the pen advance; TJ may encode tracking instead.
let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 };
let trm0 = tm.then(&ts.ctm);
let start = trm0.apply(0.0, ts.rise);
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();
let mut in_word = false;
let mut glyph_count = 0_usize;
for p in &pieces {
match p {
Object::String(s) => {
for (code, len) in m.codes(&s.bytes) {
glyph_count = glyph_count.saturating_add(1);
let shown = m.text_of(code);
let blank = m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
match shown {
Some(t) => {
carry.text_left = carry
.text_left
.checked_sub(t.len())
.ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?;
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:
// the word is the union of its glyphs, whichever way they go.
let (lo, hi) = (x_text.min(ink_end), x_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;
let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
x_text += w * ts.scale;
}
}
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 - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
// Tracking and kerning keep a word together; a larger jump
// ends it (whatever the sign of the size or `Tz`).
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 {
in_word = false;
}
x_text += dx;
}
}
}
}
*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 `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)]);
// The same corners along and across the baseline: the run's own box, not its
// upright bounding box turned (which is wider by the box's height on a slant).
let dir = direction(&trm0);
let frame = corners
.iter()
.map(|&p| to_frame(dir, p))
.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)]);
let ink = words
.iter()
.map(|&(a, b)| {
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)])
})
.collect();
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
// A numeric-only TJ adjusts the current text matrix but paints no glyphs.
// It must not create a phantom editable line or enlarge its neighbours' boxes.
if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) {
return Ok(());
}
out.push(Shown {
op: i,
extra_ops: Vec::new(),
glyph_count,
// 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 },
tracking: 0.0,
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: *tlm,
bt_op: at_bt.0,
bt_state: at_bt.1.clone(),
text,
rect,
frame,
ink,
baseline: start.1,
start_x: start.0,
end_x: end.0,
end_y: end.1,
bt,
font: name,
base_font: m.base_font.clone(),
face: ts.face.clone(),
size: if size_user > 0.0 { size_user } else { ts.size },
bold: m.bold,
italic: m.italic,
visual_order: m.visual_order,
decodable,
scope: marked.regions.last().copied().flatten(),
paint: Paint { layers: marked.layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() },
logical_order: false,
actual_text: false,
});
Ok(())
}
pub(super) fn interpret(
doc: &Document,
ops: &[Op],
resources: &Dict,
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>,
) -> Result<Vec<Shown>, EditError> {
let fonts_res = resources.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let states = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default();
let mut out: Vec<Shown> = Vec::new();
let mut ts = carry.ts.clone();
let mut at_bt = (0usize, ts.clone());
let mut stack: Vec<Ts> = std::mem::take(&mut carry.stack);
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
let mut bt = 0usize;
let mut marked =
MarkedContent { regions: std::mem::take(&mut carry.marked), layers: std::mem::take(&mut carry.layers), ..MarkedContent::default() };
for (i, op) in ops.iter().enumerate() {
match op.op.as_slice() {
// No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing
// sequence (#3573). Both stacks stay bounded by the number of operators.
b"BMC" | b"BDC" => {
marked.begin(doc, op, i, logical_text, out.len());
}
b"EMC" => {
marked.end(i, &mut out);
}
b"q" => stack.push(ts.clone()),
b"Q" => {
if let Some(s) = stack.pop() {
ts = s;
}
}
b"cm" => {
if let Some(m) = op.nums::<6>() {
ts.ctm = Matrix(m).then(&ts.ctm);
}
}
b"BT" => {
tm = Matrix::IDENTITY;
tlm = Matrix::IDENTITY;
bt += 1;
at_bt = (i, ts.clone());
}
b"g" | b"rg" | b"k" | b"cs" => ts.fill = vec![op.clone()],
b"sc" | b"scn" => {
ts.fill.retain(|o| o.is("cs"));
ts.fill.push(op.clone());
}
b"G" | b"RG" | b"K" | b"CS" => ts.stroke = vec![op.clone()],
b"SC" | b"SCN" => {
ts.stroke.retain(|o| o.is("CS"));
ts.stroke.push(op.clone());
}
b"w" => ts.line_width = op.num(0).filter(|w| w.is_finite()).or(ts.line_width),
b"d" => ts.dash = dash_pattern(doc, op.operands.first(), op.operands.get(1)).unwrap_or_else(|| ts.dash.clone()),
b"J" => ts.line_cap = line_cap(doc, op.operands.first()).unwrap_or(ts.line_cap),
b"Tf" => {
ts.size = op.num(1).unwrap_or(ts.size);
if let Some(name) = op.name(0) {
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, vertical)
});
ts.font = Some((name.to_vec(), m.clone()));
ts.face = face.clone();
// 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),
b"Tw" => ts.word_spacing = op.num(0).unwrap_or(0.0),
b"Tz" => ts.scale = op.num(0).unwrap_or(100.0) / 100.0,
b"TL" => ts.leading = op.num(0).unwrap_or(0.0),
b"Ts" => ts.rise = op.num(0).unwrap_or(0.0),
b"Tr" => ts.render_mode = op.operands.first().and_then(Object::as_int).unwrap_or(-1),
b"gs" => {
if let Some(name) = op.name(0) {
select_gs(&mut ts.ext_gstate, name);
}
let params = op.name(0).and_then(|name| states.get(name)).map(|p| doc.resolve(p));
if let Some(params) = params.as_deref().and_then(Object::as_dict) {
ts.gs.apply(doc, params);
// `/LW` sets the line width as `w` does (ISO 32000-2 §8.4.5).
let lw = params.get(b"LW").and_then(|w| doc.resolve(w).as_f64()).filter(|w| w.is_finite());
ts.line_width = lw.or(ts.line_width);
let dash = params.get(b"D").map(|d| doc.resolve(d));
let dash = dash.as_deref().and_then(Object::as_array);
if let Some(d) = dash.and_then(|d| dash_pattern(doc, d.first(), d.get(1))) {
ts.dash = d;
}
ts.line_cap = line_cap(doc, params.get(b"LC")).unwrap_or(ts.line_cap);
}
}
b"Do" => {
if let (Some(list), Some(name)) = (invoked.as_deref_mut(), op.name(0)) {
list.push((i, name.to_vec(), ts.clone()));
}
}
b"Td" | b"TD" => {
if let Some([x, y]) = op.nums::<2>() {
if op.is("TD") {
ts.leading = -y;
}
tlm = Matrix([1.0, 0.0, 0.0, 1.0, x, y]).then(&tlm);
tm = tlm;
}
}
b"Tm" => {
if let Some(m) = op.nums::<6>() {
tlm = Matrix(m);
tm = tlm;
}
}
b"T*" => {
tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(&tlm);
tm = tlm;
}
b"Tj" | b"TJ" | b"'" | b"\"" => {
let mut state = TextRunState { ts: &mut ts, tm: &mut tm, tlm: &mut tlm, at_bt: &at_bt, bt, marked: &marked, carry, out: &mut out };
show_text(op, i, &mut state)?;
}
_ => {}
}
}
carry.ts = ts;
carry.stack = stack;
carry.marked = marked.regions;
carry.layers = marked.layers;
positioned_tracking(&mut out);
Ok(out)
}