M5: line comments draw their leader lines and caption (ISO 32000-2 Table 178) (#4354)

A Line annotation without an appearance was drawn along /L with its
endings there, ignoring /LL, /LLE, /LLO, /Cap, /CP and /CO. With /LL,
/L gives the ends of the leader lines, not of the line itself.

- The line sits |LL| + LLO from /L, on the left of travel for a
  positive /LL; leader lines run from LLO off /L to LLE past the line.
- /LE endings are drawn at the line itself.
- /Cap true draws /Contents (or the plain text of /RC) in black
  Helvetica 10, turned with the line: Inline breaks the stroke around
  it, Top sets it above, /CO shifts it (with a tick when moved off).
- PdfCraft measurements keep /PCMeasureValue as their caption.
- undrawable_text reports caption text the standard font can't draw.
- Non-finite, huge and negative values, and a zero-length /L, are
  handled leniently; captions are cut to 256 characters.


Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mtlcl410
2026-10-24 10:30:16 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 96cf936b31
commit 4f436d3dd4
5 changed files with 477 additions and 8 deletions
+4 -2
View File
@@ -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
+208 -4
View File
@@ -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<Stream> {
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<u8> = 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<Stream> {
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<Stream> {
// 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<Stream> {
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>) -> 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<String> {
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<String> = 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 {
+227
View File
@@ -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<f64> = [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<f64> = 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 = "<?xml version=\"1.0\"?><body xmlns=\"http://www.w3.org/1999/xhtml\"><p>Dim &amp; <b>5</b>0&#x20;mm &lt;x&gt;</p></body>";
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 <x>)"), "{}", 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)));
}
+36
View File
@@ -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);
+2 -2
View File
@@ -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"