mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude Opus 5.5
parent
96cf936b31
commit
4f436d3dd4
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 & <b>5</b>0 mm <x></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)));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user