diff --git a/crates/annot/README.md b/crates/annot/README.md index 55df6d1a7..810820183 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -9,10 +9,12 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`, line and arrow (`Line` with `/LE`), freehand drawing (`Ink`) and text box (`FreeText`). - **Appearance streams** (`appearance::build`) generated from the annotation dictionary, so new and restyled comments look the same in every viewer. Highlights multiply (`/BM /Multiply`). + A line's leader lines (`/LL`, `/LLE`, `/LLO`) and caption (`/Cap`, `/CP`, `/CO`) are drawn as + ISO 32000-2 Table 178 places them, the caption in black Helvetica 10. Note icons are PdfCraft's own drawings. Text boxes use standard Helvetica (WinAnsi), with line breaking by approximate Helvetica proportions (no font program is bundled). Text it can't - draw (in a text box, callout, Fill & Sign text, measurement label or dynamic stamp's by-line; - `appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with + draw (in a text box, callout, Fill & Sign text, line caption, measurement label or dynamic + stamp's by-line; `appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with the first such character (`pdfcraft_fonts::UNDRAWN_MARK`) so the engine reports the edit and flattening refuses to make it permanent. Two refusals (`AnnotError::Undrawable`): a redaction mark's overlay text, which becomes page content when applied; and a restyle or resize of a diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 436c49b35..530438db6 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -302,13 +302,14 @@ fn text_turn(d: &Dict) -> i64 { /// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that /// they can't, which would come out as `?`: what it is (for the message) and the character. The -/// text of a text box, callout or Fill & Sign text, a measurement's label, a dynamic stamp's -/// "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of +/// text of a text box, callout or Fill & Sign text, a line's caption, a measurement's label, a +/// dynamic stamp's "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of /// it can be drawn. pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text); let (what, text) = match d.name(b"Subtype")? { b"FreeText" => ("the comment's text", text(b"Contents")?), + b"Line" if !d.contains(b"PCMeasureValue") => ("the line's caption", line_caption(d)?), b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?), b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?), // Drawn in Helvetica when the redaction is applied: refused when it is marked. @@ -389,6 +390,8 @@ pub fn build(d: &Dict) -> Option { let w = border_width(d); let mut res = Dict::new(); let mut c = String::new(); + // Content after `c` that isn't UTF-8 (WinAnsi text). + let mut tail: Vec = Vec::new(); let markup = matches!(subtype.as_slice(), b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly"); if let Some(gs) = ext_gstate(opacity, subtype == b"Highlight") { res.set(b"ExtGState".to_vec(), Object::Dict(gs)); @@ -513,13 +516,29 @@ pub fn build(d: &Dict) -> Option { return None; } let fill = interior_color(d, Some(col)).unwrap_or(col); + let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w); + let [p, q] = layout.line; c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); - c.push_str(&format!("{} {} m {} {} l S\n[] 0 d\n", n(l[0]), n(l[1]), n(l[2]), n(l[3]))); - for (end, (tip, from)) in ends.iter().zip([((l[0], l[1]), (l[2], l[3])), ((l[2], l[3]), (l[0], l[1]))]) { + for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) { + c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1))); + } + c.push_str("[] 0 d\n"); + for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) { if !line_end(&mut c, end, tip, from, w) { return None; } } + if let Some((text, m)) = layout.caption { + // The caption is black, in the standard font the measurement label uses. + tail.extend( + format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])) + .bytes(), + ); + // WinAnsi bytes go into the stream as-is, not through UTF-8. + tail.extend(literal(&win_ansi(&text))); + tail.extend_from_slice(b" Tj ET Q\n"); + res.set(b"Font".to_vec(), helvetica_resource()); + } } b"Polygon" | b"PolyLine" => { let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; @@ -866,6 +885,7 @@ pub fn build(d: &Dict) -> Option { // PdfCraft measurement captions are kept separate from the comment's free-form text. // A restyle regenerates the path and its value together. let mut out = c.into_bytes(); + out.extend(tail); if let Some(value) = d.get(b"PCMeasureValue").and_then(Object::as_string) { let value = value.to_text(); if value.chars().count() <= 256 && matches!(subtype.as_slice(), b"Line" | b"PolyLine" | b"Polygon") { @@ -969,6 +989,190 @@ pub(crate) fn display_stamp(d: &Dict) -> Option { Some(form(rect, c.as_bytes(), res)) } +/// Size of a line's caption, as of a measurement's label. +const CAPTION_SIZE: f64 = 10.0; + +/// Characters of a line's caption drawn at most, as for a measurement's label. +const MAX_CAPTION_CHARS: usize = 256; + +/// Leader lengths and caption offsets (Table 178) are held within this many points (200 inches, +/// the largest page side ISO 32000-2 Annex C allows), so an absurd value can't overflow. +const MAX_LINE_EXTENT: f64 = 14_400.0; + +/// A Table 178 length or offset: finite and within [`MAX_LINE_EXTENT`], 0 when absent or not one. +fn line_length(v: Option) -> f64 { + v.filter(|v| v.is_finite()).map_or(0.0, |v| v.clamp(-MAX_LINE_EXTENT, MAX_LINE_EXTENT)) +} + +/// The caption a Line with `/Cap true` shows (ISO 32000-2 Table 178): its `/Contents`, else the +/// plain text of its `/RC`, on one line and cut to [`MAX_CAPTION_CHARS`]. `None` without a +/// caption, and for PdfCraft's own measurements, whose label (`/PCMeasureValue`) is the caption. +fn line_caption(d: &Dict) -> Option { + if !matches!(d.get(b"Cap"), Some(Object::Bool(true))) || d.contains(b"PCMeasureValue") { + return None; + } + let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text); + let raw = text(b"Contents").filter(|t| !t.trim().is_empty()).or_else(|| text(b"RC").map(|rc| rich_plain(&rc)))?; + let words: Vec<&str> = raw.split_whitespace().collect(); + let caption: String = words.join(" ").chars().filter(|c| !c.is_control()).take(MAX_CAPTION_CHARS).collect(); + (!caption.is_empty()).then_some(caption) +} + +/// The text of rich text (§12.7.4.3, XHTML): its markup dropped, the five XML entities and +/// numeric character references read, block ends read as spaces. +fn rich_plain(rc: &str) -> String { + let mut out = String::new(); + let mut tag: Option = None; + // Rich text past this is not caption material; it bounds the work. + for ch in rc.chars().take(MAX_CAPTION_CHARS * 64) { + match (&mut tag, ch) { + (None, '<') => tag = Some(String::new()), + (Some(name), '>') => { + let name = + name.trim_start_matches('/').split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default().to_ascii_lowercase(); + if matches!(name.as_str(), "p" | "br" | "div" | "li") { + out.push(' '); + } + tag = None; + } + (Some(name), c) => { + if name.len() < 16 { + name.push(c); + } + } + (None, c) => out.push(c), + } + } + let mut text = String::new(); + let mut rest = out.as_str(); + while let Some(at) = rest.find('&') { + text.push_str(rest.get(..at).unwrap_or_default()); + let after = rest.get(at + 1..).unwrap_or_default(); + let entity = after.find(';').filter(|&end| end <= 10).and_then(|end| Some((after.get(..end)?, end))); + let decoded = entity.and_then(|(name, end)| { + let c = match name { + "amp" => '&', + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" => '\'', + _ => { + let num = name.strip_prefix('#')?; + let code = match num.strip_prefix(['x', 'X']) { + Some(hex) => u32::from_str_radix(hex, 16).ok()?, + None => num.parse().ok()?, + }; + char::from_u32(code)? + } + }; + Some((c, end)) + }); + match decoded { + Some((c, end)) => { + text.push(c); + rest = after.get(end + 1..).unwrap_or_default(); + } + None => { + text.push('&'); + rest = after; + } + } + } + text.push_str(rest); + text +} + +/// Where a Line annotation's parts go (ISO 32000-2 §12.5.6.7, Table 178). +#[derive(Debug, Default)] +struct LineLayout { + /// The line itself, start to end: `/L`, or `/L` moved out by its leader lines. + line: [(f64, f64); 2], + /// Strokes along the line: the whole line, or the two pieces either side of an inline caption. + strokes: Vec<[(f64, f64); 2]>, + /// The leader lines, from `/L` (past the `/LLO` gap) to the line and on by `/LLE`. + leaders: Vec<[(f64, f64); 2]>, + /// A short stroke from the line to a caption `/CO` moved off it. + tick: Option<[(f64, f64); 2]>, + /// The caption and its text matrix. + caption: Option<(String, [f64; 6])>, +} + +/// Lay out a Line annotation from `/L` = `[a b]`, line width `w`, and its leader and caption +/// entries. +/// +/// Leader lines: a positive `/LL` puts the line on the left of travel from `a` to `b` (above `/L` +/// for a line drawn left to right), so the leader lines, followed from the line to `/L`, turn +/// clockwise from the direction of travel; a negative one puts it on the right. The line sits +/// `|LL| + LLO` from `/L`. `/LLE` and `/LLO` are non-negative: a negative one counts as 0. +/// +/// Caption (`/Cap true`): centred on the line's midpoint and turned with it so it reads left to +/// right (bottom to top for a vertical line). `/CP /Inline` (the default) centres it on the line, +/// which is broken around it; `/Top` sets it on top of the unbroken line. `/CO [h v]` moves it `h` +/// along the line and `v` across it (positive: up in reading direction); a caption moved off an +/// inline line gets a tick from the line to it. +/// +/// A zero-length or overflowing `/L` has no direction: it gets neither leader lines nor caption. +/// Every point is finite; if anything derived from the entries is not, they are left out. +fn line_layout(d: &Dict, [a, b]: [(f64, f64); 2], w: f64) -> LineLayout { + let plain = LineLayout { line: [a, b], strokes: vec![[a, b]], ..LineLayout::default() }; + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = dx.hypot(dy); + if !len.is_finite() || len <= 0.0 { + return plain; + } + let (ux, uy) = (dx / len, dy / len); + let mut out = LineLayout { line: [a, b], ..LineLayout::default() }; + let ll = line_length(d.get(b"LL").and_then(Object::as_f64)); + if ll != 0.0 { + // The unit normal from `/L` towards the line: left of travel for a positive /LL. + let s = ll.signum(); + let (nx, ny) = (-uy * s, ux * s); + let llo = line_length(d.get(b"LLO").and_then(Object::as_f64)).max(0.0); + let lle = line_length(d.get(b"LLE").and_then(Object::as_f64)).max(0.0); + let at = |p: (f64, f64), k: f64| (p.0 + nx * k, p.1 + ny * k); + let off = ll.abs() + llo; + out.line = [at(a, off), at(b, off)]; + out.leaders = vec![[at(a, llo), at(a, off + lle)], [at(b, llo), at(b, off + lle)]]; + } + let [p, _] = out.line; + let along = |t: f64| (p.0 + ux * t, p.1 + uy * t); + let mut gap = None; + if let Some(text) = line_caption(d) { + let size = CAPTION_SIZE; + let width = text_width(&text, size); + // Reading direction, and "up" across it. + let (tx, ty) = if ux < 0.0 || (ux == 0.0 && uy < 0.0) { (-ux, -uy) } else { (ux, uy) }; + let (vx, vy) = (-ty, tx); + let co = nums(d, b"CO").filter(|c| c.len() == 2).unwrap_or_default(); + let h = line_length(co.first().copied()); + let v = line_length(co.get(1).copied()); + let top = d.name(b"CP") == Some(b"Top"); + // The caption's centre, as a distance along the line from `p`. + let centre = len / 2.0 + h * (tx * ux + ty * uy); + let mid = along(centre); + // Baseline across the line: on top of it, or with the text centred on it. + let base = if top { w.max(0.0) / 2.0 + 2.0 + v } else { v - size * 0.35 }; + let origin = (mid.0 - tx * width / 2.0 + vx * base, mid.1 - ty * width / 2.0 + vy * base); + out.caption = Some((text, [tx, ty, -ty, tx, origin.0, origin.1])); + if !top && v.abs() < size / 2.0 { + let half = width / 2.0 + 2.0; + gap = Some(((centre - half).clamp(0.0, len), (centre + half).clamp(0.0, len))); + } else if !top { + let reach = v - v.signum() * size * 0.4; + out.tick = Some([mid, (mid.0 + vx * reach, mid.1 + vy * reach)]); + } + } + out.strokes = match gap { + Some((g0, g1)) => [(0.0, g0), (g1, len)].into_iter().filter(|(s, e)| e > s).map(|(s, e)| [along(s), along(e)]).collect(), + None => vec![out.line], + }; + let finite = |q: &(f64, f64)| q.0.is_finite() && q.1.is_finite(); + let all_finite = + out.line.iter().chain(out.strokes.iter().flatten()).chain(out.leaders.iter().flatten()).chain(out.tick.iter().flatten()).all(finite) + && out.caption.as_ref().is_none_or(|(_, m)| m.iter().all(|x| x.is_finite())); + if all_finite { out } else { plain } +} + /// A rubber stamp: a rounded frame (a pointed tag for sign-here stamps) with the label in bold /// capitals, and the dynamic stamps' "By … at …" line. fn stamp(label: &str, sign_here: bool, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index c3602d8b5..4f0e0fb1f 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1867,3 +1867,230 @@ fn a_caret_with_a_paragraph_symbol_draws_the_pilcrow() { let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().unwrap(); assert!(appearance::build(&d).is_some()); } + +// ── Line annotations: leader lines and captions (ISO 32000-2 §12.5.6.7, Table 178) ────────── + +/// A red Line from `l` with a 1 pt border, a generous `/Rect`, and `extra` entries. +fn table_178_line(l: [f64; 4], extra: &[(&str, Object)]) -> Dict { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Line")); + d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 600.0, 800.0].map(Object::Real).to_vec())); + d.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].map(Object::Real).to_vec())); + d.set(b"L".to_vec(), Object::Array(l.map(Object::Real).to_vec())); + for (k, v) in extra { + d.set(k.as_bytes().to_vec(), v.clone()); + } + d +} + +/// The built appearance's content. +fn line_drawing(d: &Dict) -> String { + let s = crate::appearance::build(d).expect("the line is drawn"); + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() +} + +/// Every straight two-point stroke (`x0 y0 m x1 y1 l S`) in a drawing. +fn segments(content: &str) -> Vec<[f64; 4]> { + content + .lines() + .filter_map(|line| { + let t: Vec<&str> = line.split_whitespace().collect(); + if t.len() != 7 || t[2] != "m" || t[5] != "l" || t[6] != "S" { + return None; + } + let v: Vec = [t[0], t[1], t[3], t[4]].iter().map(|x| x.parse().unwrap()).collect(); + Some([v[0], v[1], v[2], v[3]]) + }) + .collect() +} + +/// The caption's text matrix (`a b c d e f Tm`), if any. +fn caption_matrix(content: &str) -> Option<[f64; 6]> { + let at = content.find(" Tm")?; + let before: Vec = content[..at].split_whitespace().rev().take(6).map(|x| x.parse().unwrap()).collect(); + Some([before[5], before[4], before[3], before[2], before[1], before[0]]) +} + +fn has(segs: &[[f64; 4]], want: [f64; 4]) -> bool { + segs.iter().any(|s| s.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01)) +} + +#[test] +fn a_positive_leader_length_puts_the_line_above_a_left_to_right_line_and_a_negative_one_below() { + let up = segments(&line_drawing(&table_178_line([72.0, 620.0, 272.0, 620.0], &[("LL", Object::Real(20.0))]))); + assert!(has(&up, [72.0, 640.0, 272.0, 640.0]), "the line is 20 above /L: {up:?}"); + assert!(has(&up, [72.0, 620.0, 72.0, 640.0]) && has(&up, [272.0, 620.0, 272.0, 640.0]), "leader lines from /L up to it: {up:?}"); + assert!(!has(&up, [72.0, 620.0, 272.0, 620.0]), "nothing along /L itself: {up:?}"); + + let down = segments(&line_drawing(&table_178_line([72.0, 540.0, 272.0, 540.0], &[("LL", Object::Real(-20.0)), ("LLE", Object::Real(10.0))]))); + assert!(has(&down, [72.0, 520.0, 272.0, 520.0]), "the line is 20 below /L: {down:?}"); + assert!(has(&down, [72.0, 540.0, 72.0, 510.0]) && has(&down, [272.0, 540.0, 272.0, 510.0]), "leaders run 10 past it: {down:?}"); +} + +#[test] +fn a_leader_offset_leaves_a_gap_and_the_endings_sit_on_the_line_itself() { + let d = table_178_line( + [72.0, 460.0, 272.0, 460.0], + &[("LL", Object::Real(20.0)), ("LLO", Object::Real(10.0)), ("LE", Object::Array(vec![Object::name("OpenArrow"), Object::name("OpenArrow")]))], + ); + let content = line_drawing(&d); + let segs = segments(&content); + assert!(has(&segs, [72.0, 490.0, 272.0, 490.0]), "the line sits |LL| + LLO above /L: {segs:?}"); + assert!(has(&segs, [72.0, 470.0, 72.0, 490.0]) && has(&segs, [272.0, 470.0, 272.0, 490.0]), "leaders start LLO from /L: {segs:?}"); + // The open arrows' tips (`tip l`) are at the ends of the line, not of /L. + assert!(content.contains(" 72 490 l ") && content.contains(" 272 490 l "), "arrows at the moved line: {content}"); + assert!(!content.contains(" 72 460 l "), "no arrow on /L: {content}"); +} + +#[test] +fn an_inline_caption_breaks_the_line_around_its_text() { + let d = + table_178_line([72.0, 380.0, 272.0, 380.0], &[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text("Inline 50 mm")))]); + let content = line_drawing(&d); + assert!(content.contains("(Inline 50 mm) Tj"), "the caption is drawn: {content}"); + assert!(content.contains("/Helv 10 Tf"), "in Helvetica 10: {content}"); + let m = caption_matrix(&content).expect("text matrix"); + let width = crate::appearance::text_width("Inline 50 mm", 10.0); + assert!((m[4] - (172.0 - width / 2.0)).abs() < 0.01 && m[5] < 380.0 && m[5] > 380.0 - 10.0, "centred on the line: {m:?}"); + let segs = segments(&content); + assert_eq!(segs.len(), 2, "two pieces: {segs:?}"); + assert!(segs.iter().all(|s| s[2] <= 172.0 - width / 2.0 || s[0] >= 172.0 + width / 2.0), "no stroke under the text: {segs:?}"); + assert!(has(&segs, [72.0, 380.0, 172.0 - width / 2.0 - 2.0, 380.0]), "the left piece starts at the line's start: {segs:?}"); +} + +#[test] +fn a_top_caption_sits_above_the_unbroken_line() { + let d = table_178_line( + [72.0, 300.0, 272.0, 300.0], + &[("Cap", Object::Bool(true)), ("CP", Object::name("Top")), ("Contents", Object::String(PdfString::text("Top 50 mm")))], + ); + let content = line_drawing(&d); + let m = caption_matrix(&content).expect("text matrix"); + assert!(m[5] > 300.0 && m[5] < 305.0, "baseline just above the line: {m:?}"); + assert_eq!(segments(&content), vec![[72.0, 300.0, 272.0, 300.0]], "the line is whole"); +} + +#[test] +fn a_caption_offset_moves_it_along_and_off_the_line_with_a_tick() { + let co = |h: f64, v: f64| Object::Array(vec![Object::Real(h), Object::Real(v)]); + let text = ("Contents", Object::String(PdfString::text("Offset 15"))); + let base = caption_matrix(&line_drawing(&table_178_line([330.0, 700.0, 530.0, 700.0], &[("Cap", Object::Bool(true)), text.clone()]))).unwrap(); + let content = line_drawing(&table_178_line([330.0, 700.0, 530.0, 700.0], &[("Cap", Object::Bool(true)), ("CO", co(0.0, 15.0)), text.clone()])); + let up = caption_matrix(&content).unwrap(); + assert!((up[5] - base[5] - 15.0).abs() < 0.01 && (up[4] - base[4]).abs() < 0.01, "15 up: {base:?} → {up:?}"); + let segs = segments(&content); + assert!(has(&segs, [330.0, 700.0, 530.0, 700.0]), "a caption off the line leaves it whole: {segs:?}"); + assert!(segs.iter().any(|s| s[0] == 430.0 && s[2] == 430.0 && s[1] == 700.0 && s[3] > 705.0), "a tick from the midpoint up: {segs:?}"); + let along = + caption_matrix(&line_drawing(&table_178_line([330.0, 700.0, 530.0, 700.0], &[("Cap", Object::Bool(true)), ("CO", co(-30.0, 0.0)), text]))) + .unwrap(); + assert!((along[4] - base[4] + 30.0).abs() < 0.01 && (along[5] - base[5]).abs() < 0.01, "30 left: {base:?} → {along:?}"); +} + +#[test] +fn without_cap_true_no_caption_is_drawn() { + for cap in [None, Some(Object::Bool(false)), Some(Object::Int(1))] { + let mut extra = vec![("Contents", Object::String(PdfString::text("Hidden caption")))]; + if let Some(c) = cap.clone() { + extra.push(("Cap", c)); + } + let d = table_178_line([330.0, 620.0, 530.0, 620.0], &extra); + let content = line_drawing(&d); + assert!(!content.contains("Tj"), "{cap:?}: {content}"); + assert_eq!(segments(&content), vec![[330.0, 620.0, 530.0, 620.0]]); + assert!(crate::appearance::undrawable_text(&d).is_none()); + } +} + +#[test] +fn a_diagonal_lines_leaders_are_perpendicular_and_its_caption_turns_with_it() { + let d = table_178_line( + [330.0, 460.0, 530.0, 560.0], + &[ + ("LL", Object::Real(20.0)), + ("LLE", Object::Real(10.0)), + ("Cap", Object::Bool(true)), + ("Contents", Object::String(PdfString::text("Diagonal"))), + ], + ); + let content = line_drawing(&d); + let segs = segments(&content); + let (ux, uy) = (200.0 / 100f64.hypot(200.0), 100.0 / 100f64.hypot(200.0)); + let leaders: Vec<&[f64; 4]> = segs.iter().filter(|s| (s[2] - s[0]) * ux + (s[3] - s[1]) * uy < 1e-2).collect(); + assert_eq!(leaders.len(), 2, "two leader lines across the line: {segs:?}"); + for l in leaders { + let (dx, dy) = (l[2] - l[0], l[3] - l[1]); + assert!((dx.hypot(dy) - 30.0).abs() < 0.01, "|LL| + LLE long: {l:?}"); + assert!(dx < 0.0 && dy > 0.0, "up and to the left (left of travel): {l:?}"); + } + let m = caption_matrix(&content).expect("caption"); + assert!((m[0] - ux).abs() < 1e-3 && (m[1] - uy).abs() < 1e-3, "the text runs along the line: {m:?}"); + // Drawn right to left, it still reads left to right. + let back = caption_matrix(&line_drawing(&table_178_line( + [530.0, 560.0, 330.0, 460.0], + &[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text("x")))], + ))) + .unwrap(); + assert!(back[0] > 0.0, "{back:?}"); +} + +#[test] +fn a_caption_from_rich_text_and_one_the_font_cant_draw() { + let rc = "

Dim & 50 mm <x>

"; + let d = table_178_line([0.0, 0.0, 100.0, 0.0], &[("Cap", Object::Bool(true)), ("RC", Object::String(PdfString::text(rc)))]); + let s = crate::appearance::build(&d).unwrap().decoded().unwrap(); + assert!(s.windows(17).any(|w| w == b"(Dim & 50 mm )"), "{}", String::from_utf8_lossy(&s)); + let cjk = table_178_line([0.0, 0.0, 100.0, 0.0], &[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text("長さ")))]); + assert_eq!(crate::appearance::undrawable_text(&cjk), Some(("the line's caption", '長'))); + // A PdfCraft measurement's label is its caption: the comment text isn't drawn a second time. + let mut measured = cjk.clone(); + measured.set(b"PCMeasureValue".to_vec(), Object::String(PdfString::text("5 mm"))); + assert_eq!(crate::appearance::undrawable_text(&measured), None); + let content = line_drawing(&measured); + assert_eq!(content.matches(" Tj").count(), 1, "{content}"); +} + +#[test] +fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() { + let cap = ("Cap", Object::Bool(true)); + let text = ("Contents", Object::String(PdfString::text(&"w".repeat(10_000)))); + // A zero-length line has no direction: no leader lines, no caption, no NaN. + let point = + line_drawing(&table_178_line([50.0, 50.0, 50.0, 50.0], &[("LL", Object::Real(20.0)), ("LLE", Object::Real(5.0)), cap.clone(), text.clone()])); + assert!(!point.contains("NaN") && !point.contains("inf") && !point.contains("Tj"), "{point}"); + assert_eq!(segments(&point).len(), 1); + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, -1e300, f64::MAX] { + let d = table_178_line( + [0.0, 0.0, 100.0, 0.0], + &[ + ("LL", Object::Real(bad)), + ("LLE", Object::Real(bad)), + ("LLO", Object::Real(bad)), + ("CO", Object::Array(vec![Object::Real(bad), Object::Real(bad)])), + cap.clone(), + text.clone(), + ], + ); + let content = line_drawing(&d); + assert!(!content.contains("NaN") && !content.contains("inf"), "{bad}: {content}"); + for s in segments(&content) { + assert!(s.iter().all(|x| x.abs() <= 50_000.0), "{bad}: {s:?}"); + } + } + // Negative LLE and LLO count as 0. + let neg = segments(&line_drawing(&table_178_line( + [0.0, 0.0, 100.0, 0.0], + &[("LL", Object::Real(10.0)), ("LLE", Object::Real(-5.0)), ("LLO", Object::Real(-5.0))], + ))); + assert!(has(&neg, [0.0, 0.0, 0.0, 10.0]) && has(&neg, [0.0, 10.0, 100.0, 10.0]), "{neg:?}"); + // Huge endpoints whose midpoint would overflow fall back to the plain line. + let huge = line_drawing(&table_178_line([f64::MAX, f64::MAX, f64::MAX / 2.0, f64::MAX], &[("LL", Object::Real(20.0)), cap, text])); + assert!(!huge.contains("NaN") && !huge.contains("inf"), "{huge}"); + // The caption is cut to 256 characters. + let long = line_drawing(&table_178_line( + [0.0, 0.0, 100.0, 0.0], + &[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text(&"w".repeat(10_000))))], + )); + assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256"); + assert!(!long.contains(&"w".repeat(257))); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f5f438dec..dc76159d1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6413,6 +6413,42 @@ fn comments_without_appearances_are_drawn_but_not_saved() { } } +/// ISO 32000-2 Table 178: with `/LL`, `/L` gives the ends of the leader lines, and the line itself +/// is drawn `/LL` from it (above, for a positive `/LL` on a line drawn left to right). The page +/// view shows the line there, with its leaders down to `/L`, and nothing along `/L` itself. +#[test] +fn a_line_without_an_appearance_is_drawn_at_its_leader_length() { + let objs: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /Line /Rect [0 0 200 300] /L [40 100 160 100] /LL 30 /C [1 0 0] /BS << /W 4 >> >>", + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(o); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("line.pdf", None, Arc::new(bytes), None).expect("opens"); + let p = shown(&s, id, 0); + let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); + // y down: /L is row 200, the line itself row 170. + assert_eq!(px(100, 170), vec![255, 0, 0, 255], "the line, 30 above /L"); + assert_eq!(px(40, 185), vec![255, 0, 0, 255], "the leader line at the start"); + assert_eq!(px(160, 185), vec![255, 0, 0, 255], "the leader line at the end"); + assert_eq!(px(100, 200), vec![255, 255, 255, 255], "nothing along /L"); +} + /// A stamp's `/Rect` and the colour family expected inside it. type StampRect = ([f64; 4], char); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cf54a37c8..0c46f2973 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3751,8 +3751,8 @@ milestone = "M5" status = "shipped" commands = ["comment.line"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"] -notes = "" +evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/annot/src/tests.rs::a_positive_leader_length_puts_the_line_above_a_left_to_right_line_and_a_negative_one_below", "crates/annot/src/tests.rs::an_inline_caption_breaks_the_line_around_its_text"] +notes = "A line without an appearance is drawn with its leader lines (/LL, /LLE, /LLO: the line sits |LL| + LLO from /L, on the left of travel for a positive /LL) and its caption (/Cap, /CP Inline or Top, /CO), endings at the line itself (ISO 32000-2 Table 178). Not yet: the tool can't add leader lines or a caption, the caption is always black Helvetica 10 (no /DS or rich-text styling), and drawing past /Rect is clipped (/Rect isn't grown)." [[feature]] id = "comment.arrow"