mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
* M5: a comment whose colour is written [r g b null] is drawn, as CMYK with the null read as 0 (view.render-annotation-appearances) ISO 32000-2 Table 166 /C (and /IC, Tables 178, 180, 181; /MK /BC and /BG, Table 192) is an array of 0, 1, 3 or 4 numbers whose length picks the colour space. A four-entry array with a non-number in it, such as [1 0 0 null] (PDFBOX-2699), made the /C parser give up, so a shape without an appearance stream was not drawn at all. PDFium, MuPDF and Poppler all read such an array as CMYK with the non-number as 0. - annot: a four-entry /C or /IC reads non-numbers as 0; other lengths still need every entry to be a number. - annot: the properties' fill uses the same parser (it read [r g b null] as RGB before), and /C no longer drops a non-number to make three numbers. - forms: /MK /BC and /BG read a four-entry array the same way instead of dropping the non-number and drawing RGB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn * M5: comment properties report the stroke colour as drawn; a reference among four colour entries is not read as 0 (view.render-annotation-appearances) props() reads a non-FreeText /C through the appearance parser, so a [1 0 0 null] stroke reports CMYK cyan as drawn, and grey and CMYK strokes report their converted colour. A reference among the four entries of a /C array (ISO 32000-2 7.3.10) is left unreadable instead of standing in as 0, so /C [1 0 0 6 0 R] is never drawn cyan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn * M5: re-run CI (fill_sign Escape test depends on the clock's second, passes locally) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn * M5: the comment list reports a comment's colour as drawn (view.render-annotation-appearances) summaries() accepted only a three-entry /C, so a Square with /C [1 0 0 null], drawn cyan and reported cyan by props(), had no colour in the comment list; grey and CMYK colours were dropped the same way. Non-FreeText comments now read /C through drawn_color (the array itself followed when it is given by reference), keeping the three-number fallback and the FreeText /DA handling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn * M5: the render inspector lists a comment's colour as drawn (view.render-annotation-appearances) The inspector accepted only a three-entry /C, with a referenced entry read as 0, so a /C [1 0 0 null], a grey or a CMYK colour listed none, unlike the comment list and the drawing. Non-FreeText comments now read /C by the appearance builder's rule (one entry grey, four CMYK with a non-number as 0 and a reference unreadable, finite and clamped), with the three-number fallback reading referenced entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --------- Co-authored-by: mtlcl410 <chuanlin@niar.org.tw> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
321 lines
15 KiB
Rust
321 lines
15 KiB
Rust
use super::create::finite;
|
||
use super::draw::set_appearance;
|
||
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs};
|
||
use super::modify::{fillable, touch, unlocked};
|
||
use super::types::{BorderShape, FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb};
|
||
use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance};
|
||
|
||
// ── reading ─────────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// One comment as the viewer lists it (the same fields and rules as
|
||
/// `pdfcraft_render::Annotation`), read straight from the object graph. The engine uses it to
|
||
/// refresh the comment list after a comment edit without re-inspecting the whole document.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Summary {
|
||
pub page: usize,
|
||
pub index: usize,
|
||
pub subtype: String,
|
||
pub author: Option<String>,
|
||
pub contents: Option<String>,
|
||
/// `/M` as written (a PDF date string).
|
||
pub modified: Option<String>,
|
||
pub name: Option<String>,
|
||
pub in_reply_to: Option<String>,
|
||
/// The page and `/Annots` position `/IRT` points at, by reference (`/NM` need not be unique).
|
||
pub parent: Option<(usize, usize)>,
|
||
/// `/RT /Group`: grouped with the `/IRT` annotation (Replace Text's caret), not a reply to it.
|
||
pub grouped: bool,
|
||
pub rect: [f32; 4],
|
||
pub color: Option<[f32; 3]>,
|
||
pub state: Option<String>,
|
||
pub quads: Vec<[f32; 8]>,
|
||
pub locked: bool,
|
||
pub intent: Option<String>,
|
||
/// A Fill & Sign mark ([`is_fill_sign`]).
|
||
pub fill_sign: bool,
|
||
/// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`.
|
||
pub no_rotate: bool,
|
||
/// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud
|
||
/// (`/Vertices`), in user space; empty for other comments.
|
||
pub points: Vec<[f32; 2]>,
|
||
}
|
||
|
||
pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
|
||
let o = doc.resolve(d.get(key)?);
|
||
let s = match &*o {
|
||
Object::String(s) => s.to_text(),
|
||
Object::Name(n) => String::from_utf8_lossy(n).into_owned(),
|
||
_ => return None,
|
||
};
|
||
let s = s.trim_matches('\0');
|
||
// Typewriter outer whitespace belongs to the text (keep in step with render's inspector).
|
||
let name_is = |key: &[u8], expected: &[u8]| d.get(key).is_some_and(|o| doc.resolve(o).as_name() == Some(expected));
|
||
let keep_breaks = key == b"Contents" && name_is(b"Subtype", b"FreeText") && name_is(b"IT", b"FreeTextTypeWriter");
|
||
let s = if keep_breaks { s } else { s.trim() };
|
||
(!s.trim().is_empty()).then(|| s.to_owned())
|
||
}
|
||
|
||
/// Whether an annotation subtype is a comment. Links, form widgets, pop-ups and the non-markup
|
||
/// multimedia and print-production annotations (ISO 32000-2 §12.5.6: Screen, Movie, RichMedia,
|
||
/// 3D, PrinterMark, TrapNet, Watermark) are not.
|
||
pub fn is_comment_subtype(subtype: &str) -> bool {
|
||
!matches!(subtype, "Link" | "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark")
|
||
}
|
||
|
||
/// Where a reference to a reference (`5 0 obj 6 0 R endobj`) ends, as `pdfcraft_render::inspect`
|
||
/// follows it, within a few steps (a cycle stops there).
|
||
fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef {
|
||
for _ in 0..8 {
|
||
match &*doc.get(r) {
|
||
Object::Ref(next) => r = *next,
|
||
_ => break,
|
||
}
|
||
}
|
||
r
|
||
}
|
||
|
||
/// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge.
|
||
pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||
let mut out = Vec::new();
|
||
let Ok(pages) = page_refs(doc) else { return out };
|
||
let lists: Vec<Vec<Object>> = pages.iter().map(|p| annots(doc, *p)).collect();
|
||
// Where each annotation is first listed, so a reply's `/IRT` finds its parent by reference.
|
||
let mut slots: std::collections::HashMap<ObjRef, (usize, usize)> = std::collections::HashMap::new();
|
||
for (page, list) in lists.iter().enumerate() {
|
||
for (index, entry) in list.iter().enumerate() {
|
||
if let Some(r) = entry.as_ref() {
|
||
slots.entry(final_ref(doc, r)).or_insert((page, index));
|
||
}
|
||
}
|
||
}
|
||
for (page, list) in lists.iter().enumerate() {
|
||
for (index, entry) in list.iter().enumerate() {
|
||
let obj = entry.as_ref().map_or_else(|| doc.resolve(entry), |r| doc.get(final_ref(doc, r)));
|
||
let Some(d) = obj.as_dict() else { continue };
|
||
let Some(subtype) = d.name(b"Subtype").map(|s| String::from_utf8_lossy(s).into_owned()) else { continue };
|
||
if !is_comment_subtype(&subtype) {
|
||
continue;
|
||
}
|
||
let nums = |k: &[u8]| -> Vec<f32> {
|
||
d.get(k)
|
||
.map(|o| doc.resolve(o))
|
||
.and_then(|o| o.as_array().map(|a| a.iter().map(|x| doc.resolve(x).as_f64().unwrap_or(0.0) as f32).collect()))
|
||
.unwrap_or_default()
|
||
};
|
||
let r = nums(b"Rect");
|
||
let rect = if r.len() == 4 { [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] } else { [0.0; 4] };
|
||
let c = nums(b"C");
|
||
let rgb = (c.len() == 3).then(|| [c[0], c[1], c[2]]);
|
||
// Other comments report the stroke colour as it is drawn (grey, CMYK, a null entry
|
||
// read as 0), like `props`; three numbers, some given by reference, are still read.
|
||
let mut color = if subtype == "FreeText" { rgb } else { summary_color(doc, d).or(rgb) };
|
||
if subtype == "FreeText"
|
||
&& let Some(da) = text_value(doc, d, b"DA")
|
||
{
|
||
let t: Vec<&str> = da.split_whitespace().collect();
|
||
if let Some(i) = t.iter().position(|x| *x == "rg")
|
||
&& i >= 3
|
||
{
|
||
let f = |k: usize| t[k].parse::<f32>().unwrap_or(0.0);
|
||
color = Some([f(i - 3), f(i - 2), f(i - 1)]);
|
||
}
|
||
}
|
||
// Only usable quads, by the rule redaction apply reads them with, so a mark's outline
|
||
// is the area apply removes (and with none, the `/Rect` both fall back to).
|
||
let quads: Vec<[f32; 8]> = d
|
||
.get(b"QuadPoints")
|
||
.map(|o| doc.resolve(o))
|
||
.map(|a| {
|
||
let entries = a.as_array().into_iter().flatten().map(|x| doc.resolve(x).as_f64());
|
||
pdfcraft_geom::quad_points::usable_quads(entries).into_iter().map(|q| q.map(|v| v as f32)).collect()
|
||
})
|
||
.unwrap_or_default();
|
||
// Keep in step with `pdfcraft_render::inspect`.
|
||
let points = match subtype.as_str() {
|
||
"Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(),
|
||
"PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(),
|
||
_ => Vec::new(),
|
||
};
|
||
let irt = d.get(b"IRT").map(|o| o.as_ref().map_or_else(|| doc.resolve(o), |r| doc.get(final_ref(doc, r))));
|
||
let in_reply_to = irt.and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM"));
|
||
let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&final_ref(doc, r)).copied());
|
||
out.push(Summary {
|
||
page,
|
||
index,
|
||
subtype,
|
||
author: text_value(doc, d, b"T"),
|
||
// Acrobat ends lines with CR, which the interface would draw as nothing (keep in
|
||
// step with `pdfcraft_render::inspect`).
|
||
contents: text_value(doc, d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")),
|
||
modified: text_value(doc, d, b"M"),
|
||
name: text_value(doc, d, b"NM"),
|
||
in_reply_to,
|
||
parent,
|
||
grouped: d.name(b"RT") == Some(b"Group"),
|
||
rect,
|
||
color,
|
||
state: text_value(doc, d, b"State"),
|
||
quads,
|
||
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
|
||
intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())),
|
||
fill_sign: is_fill_sign(doc, d),
|
||
no_rotate: drawn_upright(doc, d),
|
||
points,
|
||
});
|
||
}
|
||
}
|
||
out.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3])));
|
||
out
|
||
}
|
||
|
||
/// Change a comment's author (`/T`), subject (`/Subj`) and, for notes, icon (`/Name`, redrawn).
|
||
/// `None` leaves a value as it is.
|
||
pub fn set_info(
|
||
doc: &mut Document,
|
||
page: usize,
|
||
index: usize,
|
||
author: Option<&str>,
|
||
subject: Option<&str>,
|
||
icon: Option<NoteIcon>,
|
||
meta: &Meta,
|
||
) -> Result<(), AnnotError> {
|
||
let (_, r) = annot_ref(doc, page, index)?;
|
||
unlocked(doc, r)?;
|
||
let is_note = annot_dict(doc, r).name(b"Subtype") == Some(b"Text");
|
||
if icon.is_some() && !is_note {
|
||
return Err(AnnotError::Invalid("only sticky notes have an icon".into()));
|
||
}
|
||
doc.update_dict(r, |d| {
|
||
if let Some(a) = author {
|
||
d.set(b"T".to_vec(), PdfString::text(a));
|
||
}
|
||
if let Some(s) = subject {
|
||
d.set(b"Subj".to_vec(), PdfString::text(s));
|
||
}
|
||
if let Some(i) = icon {
|
||
d.set(b"Name".to_vec(), Object::name(i.name()));
|
||
}
|
||
touch(d, meta);
|
||
})?;
|
||
if icon.is_some() {
|
||
set_appearance(doc, r)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// What Comment properties shows for one comment.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Props {
|
||
pub subtype: String,
|
||
pub author: String,
|
||
pub subject: String,
|
||
pub color: Option<Rgb>,
|
||
pub opacity: f64,
|
||
/// Border / line width, for shapes, lines, drawings and text boxes.
|
||
pub width: Option<f64>,
|
||
/// Notes only.
|
||
pub icon: Option<NoteIcon>,
|
||
/// `/M`, as written.
|
||
pub modified: Option<String>,
|
||
/// The appearance can be redrawn (so colour, opacity and width can change).
|
||
pub restylable: bool,
|
||
/// The Locked flag.
|
||
pub locked: bool,
|
||
/// `/LE`: two names for a line or polyline (`None` when unset), one for a callout.
|
||
pub endings: Option<Vec<LineEnding>>,
|
||
/// Whether the comment has a fill control (see [`fillable`]).
|
||
pub fillable: bool,
|
||
/// The interior colour (`None`: no fill).
|
||
pub fill: Option<Rgb>,
|
||
/// Text boxes, typewriter text and callouts: the `/DA` font size in points.
|
||
pub font_size: Option<f64>,
|
||
/// Text boxes, typewriter text and callouts: the border's own colour (`None`: the text's).
|
||
pub border_color: Option<Rgb>,
|
||
/// Text boxes and typewriter text: the border's shape (callouts have none to choose).
|
||
pub border_shape: Option<BorderShape>,
|
||
/// How opaque the fill is drawn, for a comment with a fill control (`opacity` is then its
|
||
/// line's or border's).
|
||
pub fill_opacity: Option<f64>,
|
||
/// How opaque a text box's, typewriter text's or callout's text is drawn.
|
||
pub text_opacity: Option<f64>,
|
||
}
|
||
|
||
/// The current properties of the comment at `(page, index)`.
|
||
pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||
let p = page_ref(doc, page).ok()?;
|
||
let entry = annots(doc, p).get(index).cloned()?;
|
||
let obj = doc.resolve(&entry);
|
||
let d = obj.as_dict()?;
|
||
let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned();
|
||
let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { drawn_color(d, b"C") };
|
||
let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| {
|
||
if subtype == "FreeText" {
|
||
if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) }
|
||
} else {
|
||
d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0)
|
||
}
|
||
});
|
||
Some(Props {
|
||
author: text_value(doc, d, b"T").unwrap_or_default(),
|
||
subject: text_value(doc, d, b"Subj").unwrap_or_default(),
|
||
color,
|
||
opacity: appearance::part_opacity(d).line,
|
||
width,
|
||
icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)),
|
||
modified: text_value(doc, d, b"M"),
|
||
restylable: appearance::build(doc, d).is_some(),
|
||
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
|
||
fillable: fillable(&subtype),
|
||
fill: fill_of(d),
|
||
font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1),
|
||
border_color: (subtype == "FreeText").then(|| appearance::da_border(d)).flatten(),
|
||
border_shape: (subtype == "FreeText" && !d.contains(b"CL")).then(|| BorderShape::of(d)),
|
||
fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill),
|
||
text_opacity: (subtype == "FreeText").then(|| appearance::part_opacity(d).text),
|
||
subtype,
|
||
endings: line_endings_of(d),
|
||
})
|
||
}
|
||
|
||
/// A shape’s `/IC` or FreeText `/C` as RGB, read as it is drawn. Grey and CMYK fills are converted.
|
||
fn fill_of(d: &Dict) -> Option<Rgb> {
|
||
let key: &[u8] = if d.name(b"Subtype") == Some(b"FreeText") { b"C" } else { b"IC" };
|
||
drawn_color(d, key)
|
||
}
|
||
|
||
/// `/C` as [`drawn_color`] reads it, the array itself followed when it is given by reference.
|
||
/// Only an array of at most four entries is copied out of the reference.
|
||
fn summary_color(doc: &Document, d: &Dict) -> Option<[f32; 3]> {
|
||
let rgb = match d.get(b"C") {
|
||
Some(o @ Object::Ref(_)) => {
|
||
let held = doc.resolve(o);
|
||
let entries = held.as_array().filter(|a| a.len() <= 4)?;
|
||
let mut direct = Dict::new();
|
||
direct.set(b"C".to_vec(), Object::Array(entries.clone()));
|
||
drawn_color(&direct, b"C")
|
||
}
|
||
_ => drawn_color(d, b"C"),
|
||
}?;
|
||
Some(rgb.map(|x| x as f32))
|
||
}
|
||
|
||
/// The colour array at `key` as RGB, read as the appearance builder draws it: grey and CMYK are
|
||
/// converted, and a four-entry array with a non-number in it is CMYK with that entry 0.
|
||
fn drawn_color(d: &Dict, key: &[u8]) -> Option<Rgb> {
|
||
let rgb = appearance::color(d, key).flatten()?;
|
||
finite(&rgb).then(|| rgb.map(|x| x.clamp(0.0, 1.0)))
|
||
}
|
||
|
||
/// `/LE` for a line, polyline or callout. Unknown names yield `None` so the control stays hidden.
|
||
pub(crate) fn line_endings_of(d: &Dict) -> Option<Vec<LineEnding>> {
|
||
let parse = |name: &[u8]| std::str::from_utf8(name).ok().and_then(LineEnding::parse);
|
||
match d.name(b"Subtype")? {
|
||
b"Line" | b"PolyLine" => match d.get(b"LE") {
|
||
None => Some(vec![LineEnding::None, LineEnding::None]),
|
||
Some(o) => o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| parse(e.as_name()?)).collect(),
|
||
},
|
||
b"FreeText" if d.contains(b"CL") => Some(vec![parse(d.name(b"LE").unwrap_or(b"None"))?]),
|
||
_ => None,
|
||
}
|
||
}
|