M2: preserve CJK viewer fallback across engine split

This commit is contained in:
songzhihui
2026-10-27 09:00:39 +08:00
32 changed files with 7390 additions and 5549 deletions
+3
View File
@@ -0,0 +1,3 @@
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
nextest-version = "0.9.150"
+1 -1
View File
@@ -82,7 +82,7 @@ jobs:
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev qpdf poppler-utils
# cargo-deny for the deny step of `cargo xtask ci`, which requires it under CI.
tools: cargo-nextest@0.9.149,cargo-deny@0.20.2
tools: cargo-nextest@0.9.150,cargo-deny@0.20.2
# A missing oracle fails the job instead of printing a skip notice that reads as a pass.
linux-env: PDFCRAFT_ORACLES_REQUIRED=1
windows-env: PDFCRAFT_REQUIRE_WINRES=1
+4
View File
@@ -1,5 +1,7 @@
# PdfCraft roadmap
> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 9a9813bf and preserve viewer-only CJK fallback through the engine module split; estimates unchanged)
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: document installed-font resolution helpers and regression contracts; behavior and estimates unchanged)
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: print preview excludes installed CJK fonts and isolates its font cache from the viewer; estimates unchanged)
@@ -765,3 +767,5 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-26 (M2, PR #4172 print-preview review):** Print-preview requests disable installed CJK fallback and use an isolated cache so preview font availability matches output. Viewer fallback survives switching in either direction. Synthetic GBK regressions cover inline and single-worker rendering, full pages and tiles; estimates unchanged.
- **2026-10-26 (M2, PR #4172 documentation review):** Added function documentation for installed-font script selection, bounded candidate reads, platform paths, resolver precedence and regression contracts. No runtime behavior or estimate change.
- **2026-10-27 (M2, PR #4172 conflict resolution):** Integrated main `9a9813bf` and its engine module split. Opening, layer changes and session renderer rebuilds retain display-only CJK fallback; output configuration and print-preview cache isolation are preserved. Validation passed: 16 scoped engine regressions, 412 renderer unit tests, 43 renderer integration tests (one existing corpus case ignored), 15 Combine tests, scoped Clippy, formatting, layers/assets/parity and renderer/engine WASM checks. Estimates unchanged.
+21 -21
View File
@@ -14,27 +14,27 @@
(highlight, sticky note, stamp, ...), so annotation geometry follows the layout.
-->
<style>
@font-face { font-display: block; font-family: "PC Mono"; src: url("{{ASSETS}}/fonts/JetBrainsMono-Regular.ttf"); }
@font-face { font-display: block; font-family: "PC Inter"; src: url("{{FONTS}}/Inter[opsz,wght].ttf"); font-weight: 100 900; }
@font-face { font-display: block; font-family: "PC Inter"; src: url("{{FONTS}}/Inter-Italic[opsz,wght].ttf"); font-weight: 100 900; font-style: italic; }
@font-face { font-display: block; font-family: "PC Garamond"; src: url("{{FONTS}}/EBGaramond[wght].ttf"); font-weight: 400 800; }
@font-face { font-display: block; font-family: "PC Garamond"; src: url("{{FONTS}}/EBGaramond-Italic[wght].ttf"); font-weight: 400 800; font-style: italic; }
@font-face { font-display: block; font-family: "PC Playfair"; src: url("{{FONTS}}/PlayfairDisplay[wght].ttf"); font-weight: 400 900; }
@font-face { font-display: block; font-family: "PC Playfair"; src: url("{{FONTS}}/PlayfairDisplay-Italic[wght].ttf"); font-weight: 400 900; font-style: italic; }
@font-face { font-display: block; font-family: "PC Vibes"; src: url("{{FONTS}}/GreatVibes-Regular.ttf"); }
@font-face { font-display: block; font-family: "PC Math"; src: url("{{FONTS}}/STIXTwoMath-Regular.ttf"); }
@font-face { font-display: block; font-family: "PC Amiri"; src: url("{{FONTS}}/Amiri-Regular.ttf"); }
@font-face { font-display: block; font-family: "PC Hebrew"; src: url("{{FONTS}}/NotoSansHebrew[wdth,wght].ttf"); font-weight: 100 900; }
@font-face { font-display: block; font-family: "PC Devanagari"; src: url("{{FONTS}}/NotoSansDevanagari[wdth,wght].ttf"); font-weight: 100 900; }
@font-face { font-display: block; font-family: "PC Thai"; src: url("{{FONTS}}/NotoSansThai[wdth,wght].ttf"); font-weight: 100 900; }
@font-face { font-display: block; font-family: "PC Armenian"; src: url("{{FONTS}}/NotoSansArmenian[wdth,wght].ttf"); font-weight: 100 900; }
@font-face { font-display: block; font-family: "PC Georgian"; src: url("{{FONTS}}/NotoSansGeorgian[wdth,wght].ttf"); font-weight: 100 900; }
@font-face { font-display: block; font-family: "PC Tamil"; src: url("{{FONTS}}/NotoSansTamil[wdth,wght].ttf"); font-weight: 100 900; }
@font-face { font-display: block; font-family: "PC Charis"; src: url("{{FONTS}}/CharisSIL-Regular.ttf"); }
@font-face { font-display: block; font-family: "PC XiaoWei"; src: url("{{FONTS}}/ZCOOLXiaoWei-Regular.ttf"); }
@font-face { font-display: block; font-family: "PC Mincho"; src: url("{{FONTS}}/ShipporiMincho-Regular.ttf"); }
@font-face { font-display: block; font-family: "PC Gowun"; src: url("{{FONTS}}/GowunBatang-Regular.ttf"); }
@font-face { font-display: block; font-family: "PC Emoji"; src: url("{{FONTS}}/NotoColorEmoji-Regular.ttf"); }
@font-face { font-display: swap; font-family: "PC Mono"; src: url("{{ASSETS}}/fonts/JetBrainsMono-Regular.ttf"); }
@font-face { font-display: swap; font-family: "PC Inter"; src: url("{{FONTS}}/Inter[opsz,wght].ttf"); font-weight: 100 900; }
@font-face { font-display: swap; font-family: "PC Inter"; src: url("{{FONTS}}/Inter-Italic[opsz,wght].ttf"); font-weight: 100 900; font-style: italic; }
@font-face { font-display: swap; font-family: "PC Garamond"; src: url("{{FONTS}}/EBGaramond[wght].ttf"); font-weight: 400 800; }
@font-face { font-display: swap; font-family: "PC Garamond"; src: url("{{FONTS}}/EBGaramond-Italic[wght].ttf"); font-weight: 400 800; font-style: italic; }
@font-face { font-display: swap; font-family: "PC Playfair"; src: url("{{FONTS}}/PlayfairDisplay[wght].ttf"); font-weight: 400 900; }
@font-face { font-display: swap; font-family: "PC Playfair"; src: url("{{FONTS}}/PlayfairDisplay-Italic[wght].ttf"); font-weight: 400 900; font-style: italic; }
@font-face { font-display: swap; font-family: "PC Vibes"; src: url("{{FONTS}}/GreatVibes-Regular.ttf"); }
@font-face { font-display: swap; font-family: "PC Math"; src: url("{{FONTS}}/STIXTwoMath-Regular.ttf"); }
@font-face { font-display: swap; font-family: "PC Amiri"; src: url("{{FONTS}}/Amiri-Regular.ttf"); }
@font-face { font-display: swap; font-family: "PC Hebrew"; src: url("{{FONTS}}/NotoSansHebrew[wdth,wght].ttf"); font-weight: 100 900; }
@font-face { font-display: swap; font-family: "PC Devanagari"; src: url("{{FONTS}}/NotoSansDevanagari[wdth,wght].ttf"); font-weight: 100 900; }
@font-face { font-display: swap; font-family: "PC Thai"; src: url("{{FONTS}}/NotoSansThai[wdth,wght].ttf"); font-weight: 100 900; }
@font-face { font-display: swap; font-family: "PC Armenian"; src: url("{{FONTS}}/NotoSansArmenian[wdth,wght].ttf"); font-weight: 100 900; }
@font-face { font-display: swap; font-family: "PC Georgian"; src: url("{{FONTS}}/NotoSansGeorgian[wdth,wght].ttf"); font-weight: 100 900; }
@font-face { font-display: swap; font-family: "PC Tamil"; src: url("{{FONTS}}/NotoSansTamil[wdth,wght].ttf"); font-weight: 100 900; }
@font-face { font-display: swap; font-family: "PC Charis"; src: url("{{FONTS}}/CharisSIL-Regular.ttf"); }
@font-face { font-display: swap; font-family: "PC XiaoWei"; src: url("{{FONTS}}/ZCOOLXiaoWei-Regular.ttf"); }
@font-face { font-display: swap; font-family: "PC Mincho"; src: url("{{FONTS}}/ShipporiMincho-Regular.ttf"); }
@font-face { font-display: swap; font-family: "PC Gowun"; src: url("{{FONTS}}/GowunBatang-Regular.ttf"); }
@font-face { font-display: swap; font-family: "PC Emoji"; src: url("{{FONTS}}/NotoColorEmoji-Regular.ttf"); }
@page { size: Letter; margin: 0; }
+61
View File
@@ -0,0 +1,61 @@
//! The bookmark list.
/// A bookmark as the bookmark list shows it.
#[derive(Clone, Debug, PartialEq)]
pub struct BookmarkRow {
/// Child indices from the top level, 0-based, as the edit functions take them.
pub path: Vec<usize>,
pub title: String,
/// Shown expanded (a positive `/Count`).
pub open: bool,
/// The 0-based page it goes to.
pub page: Option<usize>,
/// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say).
pub view: pdfcraft_render::DestView,
/// The title's colour (`/C`), `None` for the default black, and its style (`/F`).
pub color: Option<[f32; 3]>,
pub italic: bool,
pub bold: bool,
}
/// One page of the bookmark list, in outline order.
#[derive(Clone, Debug, PartialEq)]
pub struct BookmarkPage {
pub rows: Vec<BookmarkRow>,
/// Where the next page starts, or `None` at the end.
pub next: Option<usize>,
/// Set only when the document's structure could not be read for editing: its listing then
/// comes from the viewer's copy of the outline, which is shorter than the file's.
pub truncated: bool,
}
/// The rows of a viewer outline, in outline order, with their paths.
pub(crate) fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec<BookmarkRow> {
let mut rows = Vec::new();
// One entry per open level: its items, and how many of them have been listed.
let mut levels: Vec<(&[pdfcraft_render::OutlineItem], usize)> = vec![(items, 0)];
while let Some(&(list, next)) = levels.last() {
let Some(item) = list.get(next) else {
levels.pop();
continue;
};
if let Some(top) = levels.last_mut() {
top.1 += 1;
}
let path: Vec<usize> = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect();
rows.push(BookmarkRow {
path,
title: item.title.clone(),
open: item.open,
page: item.page,
view: item.view,
color: item.color,
italic: item.italic,
bold: item.bold,
});
if !item.children.is_empty() {
levels.push((&item.children, 0));
}
}
rows
}
+163
View File
@@ -0,0 +1,163 @@
//! The comment list and its summary.
/// The comment list, read from the object graph (as `inspect` lists comments).
pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation> {
let mut list: Vec<pdfcraft_render::Annotation> = pdfcraft_annot::summaries(doc)
.into_iter()
.map(|s| pdfcraft_render::Annotation {
page: s.page,
subtype: s.subtype,
author: s.author,
contents: s.contents,
modified: s.modified.map(|m| pdfcraft_render::pretty_date(&m)),
name: s.name,
in_reply_to: s.in_reply_to,
parent: s.parent,
grouped: s.grouped,
rect: s.rect,
color: s.color,
index: s.index,
state: s.state,
quads: s.quads,
locked: s.locked,
intent: s.intent,
fill_sign: s.fill_sign,
no_rotate: s.no_rotate,
points: s.points,
})
.collect();
pdfcraft_render::link_replies(&mut list);
list
}
/// Order of Summarize Comments.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SummarySort {
#[default]
Page,
Author,
Date,
Type,
}
impl SummarySort {
pub const ALL: [SummarySort; 4] = [SummarySort::Page, SummarySort::Author, SummarySort::Date, SummarySort::Type];
pub fn name(self) -> &'static str {
match self {
SummarySort::Page => "Page",
SummarySort::Author => "Author",
SummarySort::Date => "Date",
SummarySort::Type => "Type",
}
}
pub fn parse(s: &str) -> Option<SummarySort> {
Self::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s))
}
}
/// Acrobat's name for a comment type, as comment lists show it.
pub fn comment_type_name(subtype: &str) -> &str {
match subtype {
"Text" => "Sticky Note",
"FreeText" => "Text Box",
"Highlight" => "Highlight",
"Underline" => "Underline",
"StrikeOut" => "Strikethrough",
"Squiggly" => "Squiggly",
"Square" => "Rectangle",
"Circle" => "Oval",
"Line" => "Line",
"Ink" => "Pencil",
"Polygon" => "Polygon",
"PolyLine" => "Polygonal Line",
"Stamp" => "Stamp",
"Caret" => "Inserted Text",
"FileAttachment" => "File Attachment",
"Sound" => "Sound",
"Redact" => "Redaction",
other => other,
}
}
/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`.
pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str {
match a.intent.as_deref() {
Some("FreeTextCallout") => "Callout",
Some("PolygonCloud") => "Cloud",
Some("FreeTextTypeWriter") => "Typewriter",
_ => comment_type_name(&a.subtype),
}
}
/// The text of a comment summary: one block per comment (replies indented under it), with a
/// "Page N" heading when sorted by page. Checkmarks and status replies are left out, as are
/// pop-ups; the number is the comment's position on its page.
pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String {
use std::fmt::Write;
let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.is_comment()).collect();
// Each reply under the annotation its `/IRT` points at, in document order, gathered once.
type Children<'a> = std::collections::HashMap<(usize, usize), Vec<&'a pdfcraft_render::Annotation>>;
let mut children = Children::new();
for r in all.iter().filter(|r| r.state.is_none()) {
if let Some(parent) = r.parent {
children.entry(parent).or_default().push(r);
}
}
// Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the
// direct parent, so following one level would leave the deeper replies out of the summary.
fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) {
use std::fmt::Write;
if depth > 64 {
return;
}
for r in children.get(&(parent.page, parent.index)).into_iter().flatten() {
let indent = " ".repeat(depth + 1);
let _ =
writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or(""));
for line in r.contents.as_deref().unwrap_or("").lines() {
let _ = writeln!(out, "{indent}{line}");
}
write_replies(out, children, r, depth + 1);
}
}
let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new();
let mut last = usize::MAX;
let mut n = 0;
for a in &top {
if a.page != last {
last = a.page;
n = 0;
}
n += 1;
numbered.push((n, a));
}
match sort {
SummarySort::Page => {}
SummarySort::Author => numbered.sort_by(|x, y| x.1.author.cmp(&y.1.author)),
SummarySort::Date => numbered.sort_by(|x, y| x.1.modified.cmp(&y.1.modified)),
SummarySort::Type => numbered.sort_by(|x, y| comment_kind(x.1).cmp(comment_kind(y.1))),
}
let mut out = format!("Summary of Comments on {name}\n\n");
if numbered.is_empty() {
out.push_str("This document has no comments.\n");
return out;
}
let mut heading = usize::MAX;
for (n, a) in numbered {
if sort == SummarySort::Page && a.page != heading {
heading = a.page;
let _ = writeln!(out, "Page: {}", a.page + 1);
}
let _ = write!(out, "Number: {n} Author: {} Subject: {}", a.author.as_deref().unwrap_or(""), comment_kind(a));
if sort != SummarySort::Page {
let _ = write!(out, " Page: {}", a.page + 1);
}
let _ = writeln!(out, " Date: {}", a.modified.as_deref().unwrap_or(""));
if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) {
let _ = writeln!(out, "{c}");
}
write_replies(&mut out, &children, a, 0);
out.push('\n');
}
out
}
+713
View File
@@ -0,0 +1,713 @@
//! An open document: its working file, view data and edit state.
use std::sync::{Arc, OnceLock};
use pdfcraft_cos::{SaveOptions, write_incremental};
use pdfcraft_render::{DocInfo, RenderConfig, RenderPool};
use crate::history::Editor;
use crate::{
CalculationOrder, CheckStyle, DocId, Edit, EditError, EditableObject, FormFieldKind, Hidden, InitialView, Label, MarkKind, SecuritySummary,
SignatureInfo, TabOrder, TrustStore, XfaLayout, a11y, form_appearance, guard, js, measure, optimize, renderer_password, unrestricted,
};
#[cfg(test)]
use crate::{record_link_scan, record_marker_scan};
pub struct Document {
pub id: DocId,
pub name: String,
pub path: Option<String>,
/// The working file: what Save writes.
pub bytes: Arc<Vec<u8>>,
/// What is displayed (the page view, `page_render`, page images): `bytes`, plus appearances
/// drawn for display only for comments that have none ([`display_bytes`]). Never saved.
pub display: Arc<Vec<u8>>,
pub info: DocInfo,
pub renderer: RenderPool,
/// The password the document was opened with (needed to read attachments, etc.).
pub password: Option<String>,
/// `true` when there are edits that have not been saved.
pub dirty: bool,
/// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it.
pub(crate) generation: u64,
/// The generation last handed out by `autosave_snapshots`.
pub(crate) snapshot_generation: u64,
/// Why the document cannot be edited (e.g. encryption), if so.
pub read_only_reason: Option<String>,
/// Interactive form fields of the current state (empty without a form).
pub form: Arc<Vec<pdfcraft_forms::Field>>,
pub(crate) marker_scan: OnceLock<pdfcraft_edit::MarkerScan>,
pub(crate) links: OnceLock<Vec<pdfcraft_annot::links::LinkItem>>,
/// Signature fields, validated against the session's trust store (Signatures panel).
pub signatures: Arc<Vec<SignatureInfo>>,
pub(crate) trust: Arc<TrustStore>,
pub(crate) sig_cache: Arc<pdfcraft_sign::DigestCache>,
pub(crate) editor: Option<Editor>,
pub(crate) config: RenderConfig,
/// What field scripts printed or asked for (see [`Session::take_js_output`]).
pub(crate) js_output: js::JsOutput,
/// Dynamic XFA forms: what laying the template out produced (pages and fields are
/// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves).
pub xfa: Option<XfaLayout>,
/// The parsed template of a laid-out XFA form, for its scripts.
pub(crate) xfa_template: Option<Arc<pdfcraft_xfa::model::Template>>,
/// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]).
pub(crate) xfa_scripts_off: bool,
/// A content-only refresh keeps `info` from before the edit; an edit may have added or
/// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]).
pub(crate) fonts_stale: bool,
/// XFA forms: what was approximated, rewritten or could not be written to the XFA data
/// (also in `info.warnings`, kept there when the document is re-read).
pub xfa_warnings: Vec<String>,
/// What the last successful [`Session::apply`](crate::Session::apply) wrote that its fonts can't draw: kept as typed,
/// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it.
pub edit_notices: Vec<TextNotice>,
}
/// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn
/// with (a form field's, or the standard fonts for comments) can't draw that character. Acrobat
/// keeps such text too, drawing it with fonts PdfCraft can't embed yet.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TextNotice {
/// The form field's full name, or `None` for a comment.
pub field: Option<String>,
/// The page (0-based) of the comment or of the field's widget.
pub page: usize,
pub ch: char,
/// Other PDF viewers redraw it from the stored text (a form field, with `/NeedAppearances`
/// set); otherwise the `?` shows in every viewer.
pub redrawn_elsewhere: bool,
}
impl std::fmt::Display for TextNotice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let ch = format!("\"{}\" (U+{:04X})", self.ch.escape_debug(), u32::from(self.ch));
let page = self.page + 1;
match (&self.field, self.redrawn_elsewhere) {
(Some(name), true) => write!(
f,
"form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, PdfCraft shows ? in its place, and other PDF viewers redraw the field from the value"
),
(Some(name), false) => write!(
f,
"form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, but this PDF/A document can't ask other viewers to redraw it, so ? shows in its place everywhere"
),
(None, _) => write!(
f,
"a comment on page {page} has {ch}, which the standard fonts can't draw: its text is saved, but its appearance shows ? in its place in every viewer"
),
}
}
}
/// What `next` drew with `?` that `prev` didn't (a new or redrawn appearance carrying
/// `pdfcraft_fonts::UNDRAWN_MARK`), one notice per field or comment, in page order.
pub(crate) fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec<TextNotice> {
let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() };
let redrawn_elsewhere = pdfcraft_forms::need_appearances(next);
let mut names: Option<std::collections::HashMap<pdfcraft_cos::ObjRef, String>> = None;
let mut out: Vec<TextNotice> = Vec::new();
for (page, p) in pages.iter().enumerate() {
let annots = next.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| next.resolve(&a));
for r in annots.as_deref().and_then(pdfcraft_cos::Object::as_array).into_iter().flatten().filter_map(pdfcraft_cos::Object::as_ref) {
let now = next.get(r);
let Some(d) = now.as_dict() else { continue };
let Some(ch) = pdfcraft_fonts::undrawn_mark(d) else { continue };
// Only what this edit drew: a new annotation, or a new appearance.
let was = prev.get(r);
if was.as_dict().is_some_and(|w| w.get(b"AP") == d.get(b"AP") && pdfcraft_fonts::undrawn_mark(w) == Some(ch)) {
continue;
}
let field = (d.name(b"Subtype") == Some(b"Widget")).then(|| {
let names = names.get_or_insert_with(|| {
pdfcraft_forms::fields(next)
.into_iter()
.flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::<Vec<_>>())
.collect()
});
names.get(&r).cloned().unwrap_or_default()
});
if field.is_some() && out.iter().any(|n| n.field == field) {
continue;
}
out.push(TextNotice { redrawn_elsewhere: redrawn_elsewhere && field.is_some(), field, page, ch });
}
}
out
}
impl Document {
/// How the document opens (Document Properties ▸ Initial View).
/// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be
/// read for editing).
pub fn accessibility_check(&self, options: &a11y::Options) -> Option<a11y::Report> {
self.editor.as_ref().map(|e| a11y::check(&e.cos, options))
}
/// A script of this XFA form ran past its time limit and was abandoned, so the form's
/// scripts no longer run in this document (reopening it runs them again). The script's
/// error is in the JavaScript output; this is the state, for a viewer to show.
pub fn xfa_scripts_off(&self) -> bool {
self.xfa_scripts_off
}
/// PDF Optimizer ▸ Audit space usage.
pub fn audit_space(&self) -> Vec<optimize::SpaceUse> {
self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default()
}
/// Edit a PDF: the images `page` draws (0-based).
pub fn page_images(&self, page: usize) -> Vec<pdfcraft_edit::PageImage> {
self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default()
}
/// Mixed editable objects on one page. Source inventories are zero-based and added items
/// appear once. Rectangles are user-space points, independent of the viewer's zoom.
pub fn editable_objects(&self, page: usize) -> Result<Vec<EditableObject>, String> {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
pdfcraft_edit::editable_objects(&editor.cos, page).map_err(|e| e.to_string())
}
/// Displayed top-left-origin motion to a user-space vector. Use the linear map only,
/// so even a large crop-box origin cannot erase a small drag by cancellation.
pub fn object_move_offset(&self, page: usize, delta: [f64; 2]) -> Result<[f64; 2], String> {
let p = self.info.pages.get(page).ok_or("the page no longer exists")?;
let [x0, y0, x1, y1] = p.crop.map(f64::from);
let (a, b) = (delta[0] / f64::from(p.width.max(1e-3)), delta[1] / f64::from(p.height.max(1e-3)));
let (u, v) = match p.rotation {
90 => (b, -a),
180 => (-a, -b),
270 => (-b, a),
_ => (a, b),
};
let offset = [u * (x1 - x0), -v * (y1 - y0)];
if !offset.iter().all(|v| v.is_finite()) {
return Err("the move must be finite and non-zero".into());
}
Ok(offset)
}
/// Save image as: image `index` on `page` as a file (extension, bytes).
pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec<u8>), String> {
self.check_copying().map_err(|e| e.to_string())?;
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?;
if img.is_form {
return Err("grouped Form artwork is not a bitmap; Save Image As is only available for raster images".into());
}
pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?)
}
/// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based).
pub fn text_blocks(&self, page: usize) -> Vec<pdfcraft_edit::TextBlock> {
self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default()
}
/// Saved measurement annotations, calculated from their geometry and PDF scales, plus
/// the ones that couldn't be read (unsupported formats are skipped, not fatal).
pub fn measurements(&self) -> Result<measure::Listing, String> {
let e = self.editor.as_ref().ok_or("the document can't be read")?;
Ok(measure::list(&e.cos))
}
pub fn measurement_scale(&self, page: usize, at: measure::Point) -> Result<measure::Scale, String> {
let e = self.editor.as_ref().ok_or("the document can't be read")?;
measure::scale_at(&e.cos, page, at).map_err(|e| e.to_string())
}
/// [`Self::measurement_scale`], measuring true size in `unit` (`per_point` of them in a
/// point) where the page sets no scale.
pub fn measurement_scale_or(&self, page: usize, at: measure::Point, per_point: f64, unit: &str) -> Result<measure::Scale, String> {
let e = self.editor.as_ref().ok_or("the document can't be read")?;
measure::scale_at_or(&e.cos, page, at, per_point, unit).map_err(|e| e.to_string())
}
pub fn measurement_to_user(&self, page: usize, point: measure::Point) -> Result<measure::Point, String> {
let e = self.editor.as_ref().ok_or("the document can't be read")?;
measure::view_to_user(&e.cos, page, point).map_err(|e| e.to_string())
}
pub fn measurement_to_view(&self, page: usize, point: measure::Point) -> Result<measure::Point, String> {
let e = self.editor.as_ref().ok_or("the document can't be read")?;
measure::user_to_view(&e.cos, page, point).map_err(|e| e.to_string())
}
pub fn measurement_paths(&self, page: usize) -> Result<measure::snap::Geometry, String> {
let e = self.editor.as_ref().ok_or("the document can't be read")?;
measure::snap::geometry(&e.cos, page).map_err(|e| e.to_string())
}
/// A counter that changes with every edit (for caches of derived data).
pub fn edit_generation(&self) -> u64 {
self.generation
}
/// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based).
pub fn text_lines(&self, page: usize) -> Vec<pdfcraft_edit::TextLine> {
self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default()
}
/// The form's calculation order: its calculated fields in the order they calculate, what each
/// formula reads, what is wrong with the order, and the order Fix order writes.
pub fn calculation_order(&self) -> CalculationOrder {
self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default()
}
/// Add alternate text: the figures, in document order.
pub fn figures(&self) -> Vec<a11y::Figure> {
self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default()
}
/// Reachable indirect structure tags, in document order.
pub fn tags(&self) -> Vec<a11y::TagInfo> {
self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default()
}
/// Marked-content items directly owned by one reachable indirect structure tag.
pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result<Vec<a11y::TagContentItem>, a11y::TagError> {
self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new()))
}
/// The edit that fixes `rule`, for the rules with an automatic fix: the document language
/// (`value` is the language), the title (`value` replaces it; else the current title or the
/// file name) and the tab order.
pub fn accessibility_fix(&self, rule: a11y::Rule, value: Option<&str>) -> Result<Edit, String> {
let value = value.map(str::trim).filter(|v| !v.is_empty());
match rule {
a11y::Rule::PrimaryLanguage => {
let mut v = self.initial_view();
v.language = Some(value.ok_or("give the document language (for example en-US)")?.to_owned());
Ok(Edit::SetInitialView(Box::new(v)))
}
a11y::Rule::Title => {
let title = value
.map(str::to_owned)
.or_else(|| self.info_value("Title").filter(|t| !t.trim().is_empty()))
.unwrap_or_else(|| self.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_owned());
let mut v = self.initial_view();
v.display_title = true;
Ok(Edit::Batch {
label: "Set document title".into(),
edits: vec![Edit::SetInfo { key: "Title".into(), value: title }, Edit::SetInitialView(Box::new(v))],
})
}
a11y::Rule::TabOrder => Ok(Edit::SetTabOrder { pages: (0..self.info.pages.len()).collect(), order: TabOrder::Structure }),
other => Err(format!("\"{}\" has no automatic fix", other.name())),
}
}
pub fn initial_view(&self) -> InitialView {
self.editor.as_ref().map(|e| pdfcraft_organize::initial_view(&e.cos)).unwrap_or_default()
}
/// Signed: at least one signature field holds a signature.
pub fn is_signed(&self) -> bool {
self.signatures.iter().any(|s| s.signed)
}
/// Whether comments are hidden (Comments ▸ Hide all comments).
pub fn comments_hidden(&self) -> bool {
self.config.hide_comments
}
pub fn can_undo(&self) -> Option<&str> {
self.undo_label().map(Label::as_str)
}
pub fn can_redo(&self) -> Option<&str> {
self.redo_label().map(Label::as_str)
}
/// What Undo would undo, in parts, for an interface that says it in another language.
pub fn undo_label(&self) -> Option<&Label> {
self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l)
}
/// What Redo would redo, in parts.
pub fn redo_label(&self) -> Option<&Label> {
self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l)
}
pub fn editable(&self) -> bool {
self.editor.is_some()
}
/// What the opening password allows; `None` when the document is not encrypted.
pub fn permissions(&self) -> Option<pdfcraft_cos::Permissions> {
self.editor.as_ref().and_then(|e| e.cos.permissions())
}
/// Printing is allowed (Table 22, bit 3).
pub fn allows_printing(&self) -> bool {
self.permissions().is_none_or(|p| p.print())
}
/// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying
/// selected text, snapshots, Save Image As, exporting. Assistive technology may read the
/// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask.
pub fn allows_copying(&self) -> bool {
self.permissions().is_none_or(|p| p.copy())
}
/// [`Self::allows_copying`], as the error to report when it isn't.
pub fn check_copying(&self) -> Result<(), EditError> {
if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) }
}
/// Page changes (insert, delete, rotate, move, extract) are allowed.
pub fn allows_assembly(&self) -> bool {
self.editable() && self.permissions().is_none_or(|p| p.assemble())
}
/// Redaction marks waiting to be applied.
pub fn redaction_marks(&self) -> usize {
self.info.annotations.iter().filter(|a| a.subtype == "Redact").count()
}
/// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove.
pub fn marks(&self) -> &[MarkKind] {
&self
.marker_scan
.get_or_init(|| {
self.editor
.as_ref()
.map(|e| {
#[cfg(test)]
record_marker_scan();
pdfcraft_edit::scan_markers(&e.cos)
})
.unwrap_or_default()
})
.marks
}
/// Text and images added with Edit a PDF ▸ Add content (still editable).
pub fn added(&self) -> &[pdfcraft_edit::Added] {
&self
.marker_scan
.get_or_init(|| {
self.editor
.as_ref()
.map(|e| {
#[cfg(test)]
record_marker_scan();
pdfcraft_edit::scan_markers(&e.cos)
})
.unwrap_or_default()
})
.added
}
/// Link annotations, for Edit a PDF ▸ Link.
pub fn links(&self) -> &[pdfcraft_annot::links::LinkItem] {
self.links.get_or_init(|| {
self.editor
.as_ref()
.map(|e| {
#[cfg(test)]
record_link_scan();
pdfcraft_annot::links::list(&e.cos)
})
.unwrap_or_default()
})
}
/// Changes to content and document information are allowed.
pub fn allows_modification(&self) -> bool {
self.editable() && self.permissions().is_none_or(|p| p.modify())
}
/// How widget `widget` of text field `name` lays its value out, for asking whether a typed
/// value fits it ([`form_appearance::TextBox::fits`]): a field that doesn't scroll takes no
/// more than fits. `None` without such a field or widget, or when the document can't be
/// edited.
pub fn field_text_box(&self, name: &str, widget: usize) -> Option<form_appearance::TextBox> {
let cos = &self.editor.as_ref()?.cos;
let f = self.form.iter().find(|f| f.name == name)?;
let w = f.widgets.get(widget)?;
Some(form_appearance::text_box(cos, f, w))
}
/// Filling in form fields is allowed (Table 22, bits 6 and 9).
pub fn allows_form_filling(&self) -> bool {
self.editable() && self.permissions().is_none_or(|p| p.fill_forms())
}
/// Adding and changing comments is allowed (Table 22, bit 6).
pub fn allows_annotation(&self) -> bool {
self.editable() && self.permissions().is_none_or(|p| p.annotate())
}
/// The document's security may be changed (Protect, Remove security): it is editable and,
/// if encrypted, was opened with the owner password.
pub fn allows_security_change(&self) -> bool {
self.editable() && self.permissions().is_none_or(|p| unrestricted(&p))
}
/// A password restricts the document and it wasn't opened with the owner password: entering
/// that password ([`Session::unlock`](crate::Session::unlock)) lifts the restrictions.
pub fn can_unlock(&self) -> bool {
self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p))
}
/// A summary of the document's security for Document Properties ▸ Security, including
/// protection applied in this session (written by the next save).
pub fn security_summary(&self) -> Option<SecuritySummary> {
let editor = self.editor.as_ref()?;
let h = editor.cos.output_handler()?;
let d = h.dict();
let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m);
let method = match (d.v, stream) {
(1..=3, _) if d.length_bits <= 40 => "RC4, 40-bit",
(1..=3, _) | (_, Some(pdfcraft_cos::CryptMethod::Rc4)) => "RC4, 128-bit",
(_, Some(pdfcraft_cos::CryptMethod::Aes128)) => "AES, 128-bit",
(_, Some(pdfcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit",
_ => "Attachments only",
};
let pending = editor.cos.encryption_changed();
let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() };
Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending })
}
/// Comment properties of the comment at `(page, index)` (Comment Properties dialog).
pub fn comment_props(&self, page: usize, index: usize) -> Option<pdfcraft_annot::Props> {
self.editor.as_ref().and_then(|e| pdfcraft_annot::props(&e.cos, page, index))
}
/// A form field's Appearance-tab look (borders, colours, font).
pub fn field_look(&self, name: &str) -> Option<pdfcraft_forms::Look> {
let e = self.editor.as_ref()?;
let f = self.form.iter().find(|f| f.name == name)?;
Some(pdfcraft_forms::look(&e.cos, f))
}
/// A check box's or radio button's mark (Field Properties ▸ Options), `None` for other fields.
pub fn field_check_style(&self, name: &str) -> Option<CheckStyle> {
let e = self.editor.as_ref()?;
let f = self.form.iter().find(|f| f.name == name)?;
matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f))
}
/// Whether text field `name`'s value is cut off by its box at the field's font size (#4018):
/// the page (0-based) of the first widget that cuts it, and how.
pub fn field_overflow(&self, name: &str) -> Option<(Option<usize>, pdfcraft_forms::appearance::Overflow)> {
let e = self.editor.as_ref()?;
let f = self.form.iter().find(|f| f.name == name)?;
f.widgets.iter().find_map(|w| pdfcraft_forms::appearance::value_overflow(&e.cos, f, w).map(|o| (w.page, o)))
}
/// Remove Hidden Information: what each category would remove.
pub fn hidden_info(&self) -> Vec<(Hidden, usize)> {
self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default()
}
/// Verification: faux redactions (opaque covers over extractable text) and redaction
/// marks that were never applied. Fails closed: any read error is returned, never an
/// empty list.
pub fn audit_redactions(&self) -> Result<Vec<pdfcraft_audit::AuditFinding>, String> {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
pdfcraft_audit::audit_redactions(&editor.cos).map_err(|e| e.to_string())
}
/// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes.
pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> {
self.editor.as_ref().and_then(|e| pdfcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default()
}
/// Current value of a document-information entry (Title, Author, …).
pub fn info_value(&self, key: &str) -> Option<String> {
self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key))
}
/// Whether the document information has an entry `key`, of any type, listed or not.
pub fn has_info(&self, key: &str) -> bool {
self.editor.as_ref().is_some_and(|e| pdfcraft_organize::has_info(&e.cos, key))
}
/// The custom document properties as they are now (name, text), Document Properties ▸ Custom.
pub fn custom_info(&self) -> Vec<(String, String)> {
self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default()
}
/// The name shown on the tab and window: the document title when the document asks for it
/// (Initial View ▸ Show: Document Title) and has a real one, else the file name.
pub fn display_name(&self) -> String {
self.editor
.as_ref()
.filter(|e| pdfcraft_organize::displays_doc_title(&e.cos))
.and_then(|e| pdfcraft_organize::info(&e.cos, "Title"))
.map(|t| t.trim().to_owned())
.filter(|t| !t.is_empty() && !is_placeholder_title(t))
.unwrap_or_else(|| self.name.clone())
}
/// Where each saved revision of the file ends (oldest first; `bytes[..end]` is that
/// revision). Unsaved edits aren't a revision yet.
pub fn revision_ends(&self) -> Vec<usize> {
self.editor.as_ref().map(|e| e.cos.revision_ends()).unwrap_or_default()
}
/// The file as last opened or saved, without unsaved edits: what an incremental save appends
/// to. This stays that file when undo has restored an older snapshot.
pub fn saved_bytes(&self) -> Arc<Vec<u8>> {
self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.saved.clone())
}
/// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices).
pub fn repair_log(&self) -> Vec<String> {
self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default()
}
}
/// A title that names no document: what a web browser writes when it saves a blank page or a
/// pop-up as PDF (`about:blank`), another browser-internal address, or a bare "Untitled". Such a
/// title says less than the file name, so the file name is shown instead. An address is one
/// word: "About: our company" is a real title.
pub(crate) fn is_placeholder_title(title: &str) -> bool {
let t = title.trim().to_lowercase();
let address = !t.contains(char::is_whitespace) && ["about:", "blob:", "data:"].iter().any(|p| t.starts_with(p));
address || t == "untitled"
}
/// The working file plus appearances for comments that have none, and for form fields the file
/// leaves to the viewer (#3470). Signature artwork is preserved; validation badges belong to the
/// viewer and never enter these bytes, which image/text exports and Print as Image also consume.
pub(crate) fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u8>> {
let Some(e) = editor else { return bytes.clone() };
let comments = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances);
let fields = pdfcraft_forms::with_field_appearances_cached(comments.as_ref().unwrap_or(&e.cos), &mut e.field_appearances);
let Some(display) = fields.or(comments) else {
return bytes.clone();
};
match write_incremental(&display, &SaveOptions::default()) {
Ok(written) => Arc::new(written),
Err(err) => {
e.cos.note_repair(format!("missing comment or field appearances could not be drawn ({err})"));
bytes.clone()
}
}
}
/// `comments` (the display copy with comment appearances, if it needed any; else the document
/// itself) with its form fields drawn from their values where the file leaves that to the viewer.
fn with_field_appearances(comments: Option<pdfcraft_cos::Document>, doc: &pdfcraft_cos::Document) -> Option<pdfcraft_cos::Document> {
pdfcraft_forms::with_field_appearances(comments.as_ref().unwrap_or(doc)).or(comments)
}
/// The reader shows the same pages in `a` and `b`: count, displayed size and rotation.
pub(crate) fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool {
a.len() == b.len()
&& a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation)
}
/// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read
/// the way the full inspection reads them: NULs and spaces trimmed, empty is none.
pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option<String>, Option<String>); 6] {
let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty());
[
(&mut info.title, read("Title")),
(&mut info.author, read("Author")),
(&mut info.subject, read("Subject")),
(&mut info.keywords, read("Keywords")),
(&mut info.creator, read("Creator")),
(&mut info.producer, read("Producer")),
]
}
/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for
/// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page
/// images agree with the app and independent viewers. It never changes the source document, and an
/// unparseable file, a write failure or a parser panic falls back to the original bytes.
pub fn display_bytes_for(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Arc<Vec<u8>> {
guard(|| {
pdfcraft_cos::Document::open_with_password(bytes.clone(), password)
.ok()
.and_then(|cos| with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos))
.and_then(|display| write_incremental(&display, &SaveOptions::default()).ok())
.map(Arc::new)
})
.ok()
.flatten()
.unwrap_or(bytes)
}
/// The password to give the renderer (`inspect`, `PageRenderer`) for `bytes` opened with
/// `password`. The renderer authenticates only the user password of R2–R4 files, so for their
/// owner password this is the user password that owner authentication recovers; otherwise it is
/// `password` itself. Render paths outside a [`Session`](crate::Session), such as the CLI, use this so the owner
/// password works there as it does in `doc_open`. A file that doesn't open with `password` keeps
/// it, so the renderer reports the same error it always did.
pub fn render_password_for(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Option<String> {
let password = password?;
let recovered =
guard(|| pdfcraft_cos::Document::open_with_password(bytes, Some(password)).ok().and_then(|d| recovered_render_password(&d))).ok().flatten();
Some(recovered.unwrap_or_else(|| password.to_owned()))
}
/// The user password owner authentication recovered from an R2–R4 `cos`, as the renderer takes it.
pub(crate) fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option<String> {
cos.security().and_then(|s| s.recovered_user_password()).map(|user| renderer_password(&user))
}
/// Fails when the renderer still draws text under an applied redaction mark.
///
/// Redaction decides what lies under a mark from the positions its own interpreter computes, and
/// its verify pass reuses them. Where they differ from the renderer's (a standard font without
/// /Widths, whose advances the interpreter approximates; a CMap
/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the
/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is
/// what the user saw when placing the marks, so it is asked. `after` is written as a save would
/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its
/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps
/// the document as it was.
pub(crate) fn shown_under_redactions(
after: &pdfcraft_cos::Document,
marks: &[pdfcraft_redact::Mark],
overlays: &[pdfcraft_cos::ObjRef],
pages: &[pdfcraft_render::PageInfo],
password: Option<&str>,
) -> Result<(), EditError> {
use pdfcraft_cos::Object;
use pdfcraft_redact::RedactError;
let mut marked: Vec<usize> = marks.iter().map(|m| m.page).collect();
marked.sort_unstable();
marked.dedup();
if marked.is_empty() {
return Ok(());
}
let mut shown = after.clone();
let model = pdfcraft_model::pages(&shown);
for &pi in &marked {
let Some(page) = model.get(pi) else { continue };
let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue };
let kept: Vec<Object> = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect();
shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?;
}
let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?;
let config = RenderConfig { password: password.map(Arc::from), ..Default::default() };
let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config);
let mut residue = 0;
for &pi in &marked {
let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() };
// A page that cannot be read back cannot be confirmed clean.
let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) };
let rects: Vec<[f64; 4]> = marks
.iter()
.filter(|m| m.page == pi)
.flat_map(|m| m.rects.iter())
.map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])])
.collect();
for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) {
let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from);
let area = (x1 - x0) * (y1 - y0);
if area.is_nan() || area <= 0.0 {
continue;
}
// Redaction itself takes any glyph a fifth under a mark; one that is half under it
// and still drawn was not where redaction thought it was.
let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area;
if rects.iter().any(under) {
residue += 1;
}
}
}
if residue > 0 {
return Err(RedactError::Residue(residue).into());
}
Ok(())
}
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
//! The errors editing and combining report.
use crate::measure;
/// Why a file can't be a source for Combine Files (or Insert / Replace pages).
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum SourceProblem {
#[error("it is password-protected")]
Password,
#[error("the password is wrong")]
WrongPassword,
#[error("its security settings don't allow copying pages")]
NotPermitted,
#[error("{0}")]
Unreadable(String),
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum EditError {
#[error("no such document")]
NoDocument,
#[error("this document can't be edited: {0}")]
ReadOnly(String),
#[error("the document's security settings don't allow {0}; open it with the owner password to make this change")]
NotPermitted(&'static str),
#[error("{0}")]
Organize(#[from] pdfcraft_organize::OrganizeError),
#[error("{0}")]
Bookmark(#[from] pdfcraft_organize::OutlineError),
#[error("{0}")]
Comment(#[from] pdfcraft_annot::AnnotError),
#[error(transparent)]
Measure(#[from] measure::MeasureError),
#[error("{0}")]
Protection(String),
#[error("the password is incorrect")]
WrongPassword,
#[error("{0}")]
Form(#[from] pdfcraft_forms::FormError),
#[error(transparent)]
Redact(#[from] pdfcraft_redact::RedactError),
#[error("{0}")]
Print(String),
#[error("{0}")]
Accessibility(String),
#[error(transparent)]
Data(#[from] pdfcraft_xfdf::DataError),
#[error("{0}")]
Edit(#[from] pdfcraft_edit::EditError),
#[error("{0}")]
Create(#[from] pdfcraft_create::CreateError),
#[error("the edited document could not be written: {0}")]
Write(String),
#[error("the edited document could not be reopened: {0}")]
Reopen(String),
#[error("couldn't use {0}")]
Source(String),
#[error("nothing to undo")]
NothingToUndo,
#[error("nothing to redo")]
NothingToRedo,
#[error("{0}")]
Sign(String),
#[error("{0}")]
Optimize(String),
#[error("cancelled")]
Cancelled,
#[error("{0} isn't possible in a signed document: it would rewrite the file and invalidate the signatures")]
SignedRewrite(String),
#[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")]
Signed,
#[error("{0}")]
Invalid(String),
}
impl From<pdfcraft_sign::SignError> for EditError {
fn from(e: pdfcraft_sign::SignError) -> Self {
EditError::Sign(e.to_string())
}
}
+47
View File
@@ -0,0 +1,47 @@
//! Fill & Sign geometry: typed signatures, and boxes placed as a turned page displays them.
use crate::{Shape, script_outline, signature_image};
/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user
/// space, vertically centred) and `height` points tall. `None` for text with no outlines.
///
/// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`):
/// the box runs along the displayed axes, like an image signature's
/// ([`SignatureImage::rect`](crate::SignatureImage::rect)), and the outlines are turned back so the name reads across.
pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option<Shape> {
let o = script_outline(text);
let [left, bottom, right, top] = o.bounds();
let span = (top - bottom).max(0.1);
let width = right - left;
if o.contours.is_empty() || o.width <= 0.0 || !(width > 0.0 && height > 0.0) {
return None;
}
let k = height / span;
let rect = signature_image::upright_box(rotation, at, width * k, height)?;
// Displayed right and up as user-space unit vectors: a point at (nx, ny) of the displayed box
// is at this fraction of the user-space box. Exact for an unturned page.
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]);
let to_user = |nx: f64, ny: f64| [a * nx + c * ny + (-a).max(0.0) + (-c).max(0.0), b * nx + d * ny + (-b).max(0.0) + (-d).max(0.0)];
let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect();
Some(Shape::TypedSignature { rect, contours })
}
/// `at` moved `right` and `up` points as displayed on a page turned by `rotation` (its `/Rotate`).
pub fn displayed_offset(rotation: i64, at: [f64; 2], right: f64, up: f64) -> [f64; 2] {
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]);
[at[0] + a * right + c * up, at[1] + b * right + d * up]
}
/// The user-space box `width` x `height` points as displayed on a page turned by `rotation`,
/// with its displayed top-left corner at `at`: where typed text and text boxes go. `None` for a
/// non-finite point.
pub fn text_box_at(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> {
signature_image::upright_box(rotation, displayed_offset(rotation, at, 0.0, -height / 2.0), width, height)
}
/// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types
/// it. `None` for text with no outlines.
pub fn typed_signature_graphic(text: &str) -> Option<pdfcraft_sign::Graphic> {
let o = script_outline(text);
o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours))
}
+233
View File
@@ -0,0 +1,233 @@
//! Undo/redo: the editing state of a document, its snapshots and their labels.
use std::sync::Arc;
use crate::{AppearanceCache, Edit, EditError, Shape};
/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap.
pub(crate) const MAX_UNDO: usize = 100;
/// The passwords that go with one state of a document (protection can change them).
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct Keys {
/// What the renderer and inspector open the working file with (the user password).
pub(crate) render: Option<String>,
/// What the editor re-opens a saved file with: the strongest password known (owner if any).
pub(crate) reopen: Option<String>,
}
/// What an edit can change, and so what the view data must be rebuilt from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Scope {
/// Anything: re-inspect the whole document.
Full,
/// Document-information entries; page content and appearances are unchanged.
Metadata,
/// Only comments: re-read the comment list from the object graph.
Comments,
/// Only form field values (and their widget appearances).
Form,
/// Link annotations; the full inspection is refreshed, but page-content indexes stay valid.
Links,
/// Only page content streams, resources and added content/marks: the page tree,
/// annotations, form fields, links, outline and fonts are unchanged, so the full
/// re-inspection is skipped (seconds on very large files).
Content,
}
/// Edits during which field scripts run.
pub(crate) fn uses_scripts(edit: &Edit) -> bool {
match edit {
Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true,
Edit::Batch { edits, .. } => edits.iter().any(uses_scripts),
_ => false,
}
}
pub(crate) fn scope_of(edit: &Edit) -> Scope {
match edit {
Edit::SetInfo { .. } => Scope::Metadata,
// The Attachments list is read from the whole document.
Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full,
// A file attachment also changes the Attachments list.
Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full,
Edit::AddMeasurement(_)
| Edit::AddAnnotation(_)
| Edit::AddCustomStamp { .. }
| Edit::DeleteAnnotation { .. }
| Edit::SetAnnotationContents { .. }
| Edit::ReplyToAnnotation { .. }
| Edit::SetAnnotationStatus { .. }
| Edit::MarkAnnotation { .. }
| Edit::LockAnnotation { .. }
| Edit::ReplaceText { .. }
| Edit::EraseInk { .. }
| Edit::MoveAnnotation { .. }
| Edit::ReshapeAnnotation { .. }
| Edit::ResizeAnnotation { .. }
| Edit::StyleAnnotation { .. }
| Edit::FillAnnotation { .. }
| Edit::FontSizeAnnotation { .. }
| Edit::SetAnnotationInfo { .. } => Scope::Comments,
Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links,
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form,
// Page content, resources and added content/marks only: the page tree, annotations,
// fields, links and document metadata are untouched (see `refresh_content`).
Edit::MoveObjects { .. }
| Edit::EditTextLine { .. }
| Edit::EditTextBlock { .. }
| Edit::EditPageImage { .. }
| Edit::AddText { .. }
| Edit::AddImage { .. }
| Edit::UpdateContent { .. }
| Edit::DeleteContent { .. }
| Edit::ReplaceImage { .. }
| Edit::PasteContent { .. }
| Edit::AddHeaderFooter { .. }
| Edit::AddWatermark { .. }
| Edit::AddBackground { .. }
| Edit::RemoveMarks { .. } => Scope::Content,
Edit::Batch { edits, .. } => {
let mut scopes = edits.iter().map(scope_of);
let first = scopes.next().unwrap_or(Scope::Full);
if scopes.all(|s| s == first) { first } else { Scope::Full }
}
_ => Scope::Full,
}
}
/// One undo/redo step: its label, the document state, its passwords, and what it changed.
pub(crate) type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope);
/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted).
#[derive(Clone)]
pub(crate) struct Editor {
pub(crate) cos: pdfcraft_cos::Document,
/// The file as last opened or saved. Incremental saves append to it, and Revert opens it.
/// Undo may restore a snapshot whose own base is an earlier revision; this does not follow it.
pub(crate) saved: Arc<Vec<u8>>,
/// Passwords that open [`Self::saved`].
pub(crate) saved_keys: Keys,
/// [`Self::saved`] was never written where the document belongs: a new document (combine,
/// extract, create) or one restored from recovery. It stays unsaved until a save, whatever
/// undo brings back, or closing it would lose the whole document without asking.
pub(crate) unsaved_file: bool,
pub(crate) undo: Vec<Snapshot>,
pub(crate) redo: Vec<Snapshot>,
pub(crate) keys: Keys,
/// Display appearances built for annotations without any, reused while no annotation edit
/// runs (content edits leave every annotation dictionary alone). Cleared on any refresh
/// that may change annotations; see [`AppearanceCache`].
pub(crate) appearances: AppearanceCache,
/// Display appearances drawn for form fields the file leaves to the viewer (#3470), reused
/// for each widget while the objects it was drawn from are unchanged; see
/// [`pdfcraft_forms::FieldAppearanceCache`].
pub(crate) field_appearances: pdfcraft_forms::FieldAppearanceCache,
/// The text blocks last computed for one page, reused while the document is unchanged.
pub(crate) text_blocks: TextBlocksCache,
}
/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read
/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not
/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns
/// with undo, so a stale entry never matches, also after the document has been edited.
#[derive(Default)]
pub(crate) struct TextBlocksCache(std::sync::Mutex<Option<CachedTextBlocks>>);
/// The document state, the page (0-based) and its text blocks.
type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc<Vec<pdfcraft_edit::TextBlock>>);
impl Clone for TextBlocksCache {
fn clone(&self) -> Self {
// A poisoned cache only loses the cached blocks.
Self(std::sync::Mutex::new(self.0.lock().ok().and_then(|c| c.clone())))
}
}
impl Editor {
/// The text blocks on `page` (0-based), from the cache when the document hasn't changed.
pub(crate) fn text_blocks(&self, page: usize) -> Result<Arc<Vec<pdfcraft_edit::TextBlock>>, EditError> {
if let Ok(cache) = self.text_blocks.0.lock()
&& let Some((state, p, blocks)) = cache.as_ref()
&& *p == page
&& state.matches(&self.cos)
{
return Ok(blocks.clone());
}
let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?);
if let Ok(mut cache) = self.text_blocks.0.lock() {
*cache = Some((self.cos.read_state(), page, blocks.clone()));
}
Ok(blocks)
}
}
/// What an edit is called in the Edit menu and in the history ("Undo Rotate pages"), in two parts:
/// a whole English wording, and the one value the document itself supplies for the wording's hole
/// (a file name, a field name). An interface in another language looks the wording up and says the
/// label with [`said`](Self::said); [`as_str`](Self::as_str) is the English, said the same way, so
/// the two cannot drift apart.
#[derive(Clone, Debug, PartialEq)]
pub struct Label {
/// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`](crate::EDIT_LABELS).
pub wording: &'static str,
/// What the document supplies, when the wording has a hole for it.
pub value: Option<String>,
text: String,
}
impl Label {
/// A wording with nothing to fill in.
pub fn of(wording: &'static str) -> Self {
Self { wording, value: None, text: wording.to_string() }
}
/// A wording whose hole the document fills in.
pub fn with(wording: &'static str, value: impl Into<String>) -> Self {
let mut label = Self { wording, value: Some(value.into()), text: String::new() };
label.text = label.said(wording);
label
}
/// The label read with this wording: the interface's translation of [`wording`](Self::wording),
/// or the English wording itself. What the document supplies goes in as it stands and is not
/// read again, so a name with braces in it stays whole.
pub fn said(&self, wording: &str) -> String {
let Some(value) = &self.value else { return wording.to_owned() };
// `{` and `}` are ASCII, so these are char boundaries.
match (wording.find('{'), wording.find('}')) {
(Some(open), Some(close)) if open < close => {
let mut said = String::with_capacity(wording.len() + value.len());
said.push_str(&wording[..open]);
said.push_str(value);
said.push_str(&wording[close + 1..]);
said
}
// A translation that lost its hole is said as it stands rather than losing the value.
_ => wording.to_owned(),
}
}
/// The label as English reads it.
pub fn as_str(&self) -> &str {
&self.text
}
}
impl std::fmt::Display for Label {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.text)
}
}
/// The passwords after `edit`, if it changes them.
pub(crate) fn keys_after(edit: &Edit) -> Option<Keys> {
match edit {
Edit::Protect(p) => {
Some(Keys { render: p.open_password.clone(), reopen: p.permissions_password.clone().or_else(|| p.open_password.clone()) })
}
Edit::RemoveProtection => Some(Keys::default()),
Edit::Batch { edits, .. } => edits.iter().rev().find_map(keys_after),
_ => None,
}
}
+60
View File
@@ -0,0 +1,60 @@
//! Layer (optional content) visibility: what the view shows and what printing writes.
use std::sync::Arc;
use pdfcraft_render::{Layer, LayerOp, RenderPool};
use crate::{Document, display_config, render_threads};
/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy
/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its
/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties`
/// is left alone.
pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) {
use pdfcraft_cos::{Dict, ObjRef, Object};
let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return };
let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return };
let Some(properties) = root.get(b"OCProperties").cloned() else { return };
let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return };
let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new);
let refs =
|visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect());
config.set(b"BaseState".to_vec(), Object::name("ON"));
config.set(b"ON".to_vec(), refs(true));
config.set(b"OFF".to_vec(), refs(false));
properties.set(b"D".to_vec(), Object::Dict(config));
root.set(b"OCProperties".to_vec(), Object::Dict(properties));
cos.set(root_ref, Object::Dict(root));
}
/// Render `doc` with the layer visibility in its `info.layers`.
pub(crate) fn use_layer_choices(doc: &mut Document) {
let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
doc.config.layers = Arc::new(overrides);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the
/// state the changes before it left. Returns whether any layer ended up changed.
pub(crate) fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool {
let before: Vec<bool> = layers.iter().map(|l| l.visible).collect();
let index: std::collections::HashMap<(u32, u16), usize> = layers.iter().enumerate().map(|(i, l)| (l.id, i)).collect();
for &(op, ocg) in changes {
let Some(layer) = index.get(&ocg).and_then(|&i| layers.get_mut(i)) else { continue };
let on = match op {
LayerOp::On => true,
LayerOp::Off => false,
LayerOp::Toggle => !layer.visible,
};
layer.visible = on;
// Turning a layer off leaves the rest of its groups alone.
if on && preserve_rb {
for other in groups.iter().filter(|g| g.contains(&ocg)).flatten().filter(|&&o| o != ocg) {
if let Some(l) = index.get(other).and_then(|&i| layers.get_mut(i)) {
l.visible = false;
}
}
}
}
layers.iter().zip(before).any(|(l, was)| l.visible != was)
}
+53 -5497
View File
File diff suppressed because it is too large Load Diff
+355
View File
@@ -0,0 +1,355 @@
//! Opening a document: preparing it away from the session, then registering it.
use std::sync::{Arc, OnceLock};
use pdfcraft_cos::{SaveOptions, write_incremental};
use pdfcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect};
use crate::history::{Editor, Keys, TextBlocksCache};
use crate::{
AppearanceCache, DocId, Document, Session, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, display_config, guard, info_fields, js,
note_warnings, recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets,
};
/// Immutable session state needed while a document is prepared away from its owning thread.
#[derive(Clone)]
pub struct OpenContext {
now: Option<i64>,
js_off: bool,
trust: Arc<TrustStore>,
xfa_time_scale: u32,
}
/// A fully prepared document that has not yet been registered in a session.
pub struct PreparedDocument {
document: Document,
}
impl OpenContext {
/// Prepare a document without mutating the session that supplied this context.
pub fn prepare_open(
&self,
name: impl Into<String>,
path: Option<String>,
bytes: Arc<Vec<u8>>,
password: Option<&str>,
) -> Result<PreparedDocument, OpenError> {
let name = name.into();
guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password))
.unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})"))))
}
}
impl Session {
/// Snapshot the session state that affects open-time scripts, dates and signature trust.
pub fn open_context(&self) -> OpenContext {
OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1) }
}
/// Prepare a document without adding it to this session.
pub fn prepare_open(
&self,
name: impl Into<String>,
path: Option<String>,
bytes: Arc<Vec<u8>>,
password: Option<&str>,
) -> Result<PreparedDocument, OpenError> {
self.open_context().prepare_open(name, path, bytes, password)
}
/// Register a prepared document. This is the only open step that mutates the session.
pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId {
self.next_id += 1;
let id = DocId(self.next_id);
let mut document = prepared.document;
document.id = id;
self.docs.push(document);
id
}
/// Synchronous convenience API used by the CLI and automation.
pub fn open(&mut self, name: impl Into<String>, path: Option<String>, bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocId, OpenError> {
let prepared = self.prepare_open(name, path, bytes, password)?;
Ok(self.commit_open(prepared))
}
fn prepare_open_unguarded(
context: &OpenContext,
name: String,
path: Option<String>,
bytes: Arc<Vec<u8>>,
password: Option<&str>,
) -> Result<PreparedDocument, OpenError> {
let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password));
// The renderer authenticates on its own. It cannot use the owner password of R2–R4
// files, so give it the user password that owner authentication recovers.
let (info, render_password) = match inspect(bytes.clone(), password) {
Ok(info) => (info, password.map(str::to_owned)),
Err(OpenError::WrongPassword) => {
let user = match &cos {
Ok(Ok(d)) => recovered_render_password(d),
_ => None,
};
let user = user.ok_or(OpenError::WrongPassword)?;
(inspect(bytes.clone(), Some(&user))?, Some(user))
}
Err(e) => return Err(e),
};
// A dynamic XFA form is a shell around an XML template; lay the template out into real
// pages and fields so the rest of the engine works on it. The original bytes stay: the
// laid-out form is one appended revision.
let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
// Write `work` as one more revision and reopen it: what the document then is.
let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc<Vec<u8>>, DocInfo, pdfcraft_cos::Document), String> {
let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?;
let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?;
let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?;
Ok((new_bytes, new_info, new_cos))
};
let (bytes, info, cos, xfa) = match (info.xfa, cos) {
(Some(pdfcraft_render::Xfa::Dynamic), Ok(Ok(cos))) if pdfcraft_xfa::existing_layout(&cos).is_none() => {
let mut work = cos.clone();
let laid_out = guard(|| xfa_layout(&mut work))
.unwrap_or_else(|m| Err(format!("laying it out failed unexpectedly ({m})")))
.and_then(|report| rebase(&work).map(|(b, i, c)| (b, i, c, report)));
match laid_out {
Ok((b, i, c, report)) => (b, i, Ok(Ok(c)), Some(report)),
Err(e) => {
let mut info = info;
info.warnings.push(format!("This dynamic XFA form could not be laid out: {e}"));
(bytes, info, Ok(Ok(cos)), None)
}
}
}
// A static XFA form, or a dynamic one laid out earlier: its datasets may hold values
// filled in by another viewer since; give the fields those values.
(Some(_), Ok(Ok(cos))) => {
let report = pdfcraft_xfa::existing_layout(&cos);
let mut work = cos.clone();
let synced =
guard(|| xfa_values_from_datasets(&mut work)).unwrap_or_else(|m| Err(format!("reading its data failed unexpectedly ({m})")));
match synced {
Ok(names) if names.is_empty() => (bytes, info, Ok(Ok(cos)), report),
Ok(names) => match rebase(&work) {
Ok((b, mut i, c)) => {
// The fields were rewritten from the XFA data: say so, and which.
let shown: Vec<&str> = names.iter().take(5).map(String::as_str).collect();
let more = if names.len() > shown.len() { format!(" and {} more", names.len() - shown.len()) } else { String::new() };
i.warnings.push(format!(
"{} form field value{} were taken from this form's XFA data (filled in by another viewer): {}{more}",
names.len(),
if names.len() == 1 { "" } else { "s" },
shown.join(", ")
));
(b, i, Ok(Ok(c)), report)
}
Err(e) => {
let mut info = info;
info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}"));
(bytes, info, Ok(Ok(cos)), report)
}
},
Err(e) => {
let mut info = info;
info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}"));
(bytes, info, Ok(Ok(cos)), report)
}
}
}
(_, cos) => (bytes, info, cos, None),
};
// A laid-out XFA form: its initialize and calculate scripts run now, as on opening in
// Acrobat; what they change is one more revision, still nothing to save.
let mut script_output = js::JsOutput::default();
let mut ran_away = false;
let xfa_template = match (&xfa, &cos) {
(Some(_), Ok(Ok(c))) => xfa::template(c),
_ => None,
};
let (bytes, info, cos) = match (&xfa, cos) {
(Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() {
Some(tpl) => {
let mut work = cos.clone();
let pages = info.pages.len();
let ran = guard(|| {
let mut run = xfa::XfaRun {
page_count: pages,
out: &mut script_output,
datasets: None,
ran_away: false,
time_scale: context.xfa_time_scale,
};
let changes = xfa::on_open(&mut work, &tpl, &mut run);
if run.ran_away {
ran_away = true;
}
changes
})
.unwrap_or_else(|m| Err(format!("its scripts failed unexpectedly ({m})")));
match ran {
Ok(changes) if work.is_modified() => match rebase(&work) {
Ok((b, mut i, c)) => {
// The file as saved differs from what opening shows: say so.
if !changes.is_empty() {
i.warnings.push(format!(
"This form's scripts changed it on opening ({}); saving keeps those changes",
changes.describe()
));
}
(b, i, Ok(Ok(c)))
}
Err(e) => {
script_output.errors.push(format!("The form's scripts could not be applied: {e}"));
(bytes, info, Ok(Ok(cos)))
}
},
Ok(_) => (bytes, info, Ok(Ok(cos))),
Err(e) => {
script_output.errors.push(format!("The form's scripts could not run: {e}"));
(bytes, info, Ok(Ok(cos)))
}
}
}
None => (bytes, info, Ok(Ok(cos))),
},
(_, cos) => (bytes, info, cos),
};
// The catalog leads to no page, but the reader found pages by scanning: gather the page
// objects into a page tree as one more revision, or page edits address none of them
// (#1656). Kept only when the reader then shows the same pages, and never on a signed
// document, where writing the repair could rewrite the file under its signatures.
let (bytes, info, cos) = match cos {
Ok(Ok(cos)) if !info.pages.is_empty() && pdfcraft_organize::page_count(&cos).unwrap_or(0) == 0 => {
let mut work = cos.clone();
let signed = || pdfcraft_sign::pdf::list(&cos, &bytes, &context.trust).iter().any(|s| s.signed);
let rebuilt = match pdfcraft_organize::recover_page_tree(&mut work) {
Ok(n) if n > 0 && !signed() => rebase(&work).ok().filter(|(_, i, _)| same_pages(&i.pages, &info.pages)),
_ => None,
};
match rebuilt {
Some((b, i, mut c)) => {
for line in cos.repair_log() {
c.note_repair(line.clone());
}
c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len()));
(b, i, Ok(Ok(c)))
}
None => (bytes, info, Ok(Ok(cos))),
}
}
cos => (bytes, info, cos),
};
// XFA notes survive the document being re-read after edits.
let xfa_warnings: Vec<String> = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() };
let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa);
// A script that ran away on open turns the form's scripts off for this document.
document.xfa_template = if ran_away { None } else { xfa_template };
document.js_output.append(script_output);
if ran_away {
document.xfa_scripts_off = true;
document.js_output.errors.push(XFA_SCRIPTS_OFF.into());
}
note_warnings(&mut document.xfa_warnings, &xfa_warnings);
Ok(PreparedDocument { document })
}
/// The last step of opening: build the document record and register it.
#[allow(clippy::too_many_arguments)]
fn build_document(
trust: &Arc<TrustStore>,
name: String,
path: Option<String>,
bytes: Arc<Vec<u8>>,
mut info: DocInfo,
cos: Result<Result<pdfcraft_cos::Document, pdfcraft_cos::CosError>, Box<dyn std::any::Any + Send>>,
render_password: Option<String>,
password: Option<&str>,
xfa: Option<XfaLayout>,
) -> Document {
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
let (mut editor, read_only_reason) = match cos {
Ok(Ok(cos)) => {
let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) };
let saved = cos.bytes().clone();
(
Some(Editor {
cos,
saved,
saved_keys: keys.clone(),
unsaved_file: false,
undo: Vec::new(),
redo: Vec::new(),
keys,
appearances: AppearanceCache::default(),
field_appearances: pdfcraft_forms::FieldAppearanceCache::default(),
text_blocks: TextBlocksCache::default(),
}),
None,
)
}
Ok(Err(e)) => (None, Some(e.to_string())),
Err(_) => (None, Some("the document structure could not be read for editing".into())),
};
let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default());
let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default();
// The inspection reads only `/Info`; a file stating its title or author only in XMP still
// shows them (#2373).
if let Some(e) = &editor {
for (field, value) in info_fields(&mut info, &e.cos) {
if field.is_none() {
*field = value;
}
}
}
let display = display_bytes(editor.as_mut(), &bytes);
let renderer = RenderPool::new(display.clone(), render_threads(), display_config(&config));
let (form, adopted) = editor
.as_ref()
.map(|e| {
let mut fields = pdfcraft_forms::fields_with_adopted(&e.cos);
xfa::mark_script_buttons(&e.cos, &mut fields.items);
(fields.items, fields.adopted_page_fields)
})
.unwrap_or_default();
// Fields the file lists only as page widgets are adopted leniently; the file says so.
if adopted > 0
&& let Some(e) = &mut editor
{
let line = if adopted == 1 {
"the form's /Fields list omits 1 field; it was read from the page annotations".to_string()
} else {
format!("the form's /Fields list omits {adopted} fields; they were read from the page annotations")
};
e.cos.note_repair(line);
}
Document {
id: DocId(0),
name,
path,
bytes,
display,
info,
renderer,
password: render_password,
dirty: false,
generation: 0,
snapshot_generation: 0,
read_only_reason,
form: Arc::new(form),
marker_scan: OnceLock::new(),
links: OnceLock::new(),
signatures: Arc::new(signatures),
trust: trust.clone(),
sig_cache,
editor,
config,
js_output: Default::default(),
xfa,
xfa_template: None,
xfa_scripts_off: false,
fonts_stale: false,
xfa_warnings: Vec::new(),
edit_notices: Vec::new(),
}
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Page filters: even or odd page numbers, and orientation as displayed.
/// Page filters of Acrobat's Rotate Pages and page selection: even/odd page numbers and
/// orientation (as displayed).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PageParity {
#[default]
Both,
Even,
Odd,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PageOrientation {
#[default]
Both,
Landscape,
Portrait,
}
/// The pages of `pages` (0-based) that pass the filters.
pub fn filter_pages(info: &pdfcraft_render::DocInfo, pages: &[usize], parity: PageParity, orientation: PageOrientation) -> Vec<usize> {
pages
.iter()
.copied()
.filter(|p| match parity {
PageParity::Both => true,
// Page numbers are 1-based: index 1 is page 2, an even page.
PageParity::Even => p % 2 == 1,
PageParity::Odd => p % 2 == 0,
})
.filter(|p| {
let Some(pg) = info.pages.get(*p) else { return false };
match orientation {
PageOrientation::Both => true,
PageOrientation::Landscape => pg.width > pg.height,
PageOrientation::Portrait => pg.width <= pg.height,
}
})
.collect()
}
+59
View File
@@ -0,0 +1,59 @@
//! Pictures placed on pages: added images, and the pictures of watermarks and backgrounds.
use std::sync::Arc;
use crate::EditError;
/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page`
/// (0-based) of a PDF.
#[derive(Clone, Debug, PartialEq)]
pub struct MarkFile {
pub name: String,
pub bytes: Arc<Vec<u8>>,
pub page: usize,
}
/// Embed an image that is placed on a page, turned the way its EXIF Orientation says.
pub(crate) fn embed_image(
doc: &mut pdfcraft_cos::Document,
name: &str,
bytes: &[u8],
) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> {
pdfcraft_create::image_xobject(doc, name, bytes)
}
/// The largest box of `w`:`h` shape inside `rect`, centred in it: a stamp's picture keeps its
/// shape in a box of any other shape. A box that is already that shape (to within rounding) is
/// kept exactly, so callers that size it themselves get their box back. An empty or non-finite
/// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse.
pub(crate) fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] {
if !rect.iter().all(|v| v.is_finite()) {
return rect;
}
let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
let (bw, bh) = (rect[2] - rect[0], rect[3] - rect[1]);
let usable = |v: f64| v.is_finite() && v > 0.0;
if !(usable(bw) && usable(bh) && usable(w) && usable(h)) {
return rect;
}
let k = (bw / w).min(bh / h);
let (fw, fh) = (w * k, h * k);
if (fw - bw).abs() <= bw * 1e-9 && (fh - bh).abs() <= bh * 1e-9 {
return rect;
}
let (cx, cy) = (rect[0] + bw / 2.0, rect[1] + bh / 2.0);
[cx - fw / 2.0, cy - fh / 2.0, cx + fw / 2.0, cy + fh / 2.0]
}
/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image.
pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result<pdfcraft_edit::MarkSource, EditError> {
let head = &f.bytes[..f.bytes.len().min(1024)];
if head.windows(5).any(|w| w == b"%PDF-") {
let src = pdfcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?;
let (xobject, size) = pdfcraft_organize::page_as_form(doc, &src, f.page)?;
Ok(pdfcraft_edit::MarkSource { xobject, size, image: false })
} else {
let (xobject, size) = embed_image(doc, &f.name, &f.bytes)?;
Ok(pdfcraft_edit::MarkSource { xobject, size, image: true })
}
}
+158
View File
@@ -0,0 +1,158 @@
//! Password protection to apply, and the security a document has.
use crate::EditError;
/// Document Properties ▸ Security.
#[derive(Clone, Debug, PartialEq)]
pub struct SecuritySummary {
/// "AES, 256-bit" etc.
pub method: String,
/// Opened with the owner password (no restrictions apply).
pub owner: bool,
/// For pending protection: the restrictions as they will apply to others.
pub permissions: pdfcraft_cos::Permissions,
/// Set in this session; written by the next save.
pub pending: bool,
}
/// What printing a protected document allows (Acrobat: Printing allowed).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Printing {
None,
/// "Low Resolution (150 dpi)".
Low,
High,
}
/// What changes a protected document allows (Acrobat: Changes allowed).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Changes {
None,
/// "Inserting, deleting, and rotating pages".
Pages,
/// "Filling in form fields and signing existing signature fields".
FillSign,
/// "Commenting, filling in form fields, and signing existing signature fields".
CommentFillSign,
/// "Any except extracting pages".
AnyExceptExtract,
}
/// Password protection to apply (Protect Using Password and its Advanced options).
#[derive(Clone, PartialEq)]
pub struct Protection {
/// Required to open the document (the user password).
pub open_password: Option<String>,
/// Required to change security and lift the restrictions below (the owner password).
pub permissions_password: Option<String>,
pub printing: Printing,
pub changes: Changes,
/// "Enable copying of text, images, and other content".
pub copy: bool,
/// "Enable text access for screen reader devices".
pub accessibility: bool,
/// Compatibility level; AES-256 (Acrobat X and later) by default.
pub algorithm: pdfcraft_cos::Algorithm,
/// `false`: "Encrypt all document contents except metadata".
pub encrypt_metadata: bool,
}
impl std::fmt::Debug for Protection {
// Never print passwords (edits end up in logs and error messages).
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Protection")
.field("open_password", &self.open_password.as_ref().map(|_| "…"))
.field("permissions_password", &self.permissions_password.as_ref().map(|_| "…"))
.field("printing", &self.printing)
.field("changes", &self.changes)
.field("copy", &self.copy)
.field("accessibility", &self.accessibility)
.field("algorithm", &self.algorithm)
.field("encrypt_metadata", &self.encrypt_metadata)
.finish()
}
}
impl Default for Protection {
/// Acrobat's defaults: AES-256, printing and accessibility allowed, no changes, no copying.
fn default() -> Self {
Self {
open_password: None,
permissions_password: None,
printing: Printing::High,
changes: Changes::None,
copy: false,
accessibility: true,
algorithm: pdfcraft_cos::Algorithm::Aes256,
encrypt_metadata: true,
}
}
}
impl Protection {
/// The `/P` permission bits (ISO 32000-2 Table 22), as Acrobat maps its choices.
pub fn permission_bits(&self) -> i32 {
let bit = |n: u32| 1i32 << (n - 1);
if self.permissions_password.is_none() {
return -1; // nothing restricted without a permissions password
}
let mut p = 0;
match self.printing {
Printing::None => {}
Printing::Low => p |= bit(3),
Printing::High => p |= bit(3) | bit(12),
}
p |= match self.changes {
Changes::None => 0,
Changes::Pages => bit(11),
Changes::FillSign => bit(9),
Changes::CommentFillSign => bit(6) | bit(9),
Changes::AnyExceptExtract => bit(4) | bit(6) | bit(9),
};
if self.copy {
p |= bit(5) | bit(10);
}
if self.accessibility {
p |= bit(10);
}
p
}
pub(crate) fn validate(&self) -> Result<(), EditError> {
let bad = |m: &str| Err(EditError::Protection(m.to_string()));
match (&self.open_password, &self.permissions_password) {
(None, None) => return bad("enter a password to open the document, a permissions password, or both"),
(Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"),
_ => {}
}
if [&self.open_password, &self.permissions_password].into_iter().flatten().any(String::is_empty) {
return bad("passwords can't be empty");
}
if self.algorithm != pdfcraft_cos::Algorithm::Aes256
&& [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.chars().any(|c| !(' '..='~').contains(&c)))
{
return bad("this compatibility level supports only plain ASCII passwords; use AES-256 (Acrobat X and later)");
}
// Encryption keys on a password as `effective_password` gives it (AES-256: SASLprep,
// which drops soft hyphens and folds compatibility forms; older levels: the first 32
// bytes, padded). One that comes out as the empty password opens the file without asking,
// and an open password that comes out as the permissions password lifts the restrictions.
let effective = |p: &str| pdfcraft_cos::effective_password(self.algorithm, p);
let (open, permissions) = (self.open_password.as_deref().map(effective), self.permissions_password.as_deref().map(effective));
let empty = effective("");
if open.iter().chain(&permissions).any(|p| *p == empty) {
return bad("passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens");
}
if open.is_some() && open == permissions {
return bad("the open password and the permissions password must be different (PDF readers treat these two as the same password)");
}
Ok(())
}
}
/// Opened as owner, or nothing is restricted (no permissions password was set): security may
/// be changed, as in Acrobat.
pub(crate) fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool {
const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12
p.owner || p.bits & ALL == ALL
}
+53
View File
@@ -0,0 +1,53 @@
//! What the engine re-exports from the other pdfcraft crates, so frontends need only this one.
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges};
pub use pdfcraft_organize::LabelStyle;
pub use pdfcraft_organize::Standard;
pub use pdfcraft_organize::check_custom_key;
pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation};
pub use pdfcraft_cos::Algorithm;
pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind};
pub use pdfcraft_edit::added::{
CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect,
text_width as added_text_width,
};
pub use pdfcraft_edit::{
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
};
pub use pdfcraft_forms::{
BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont,
FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger,
Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by,
calculations_changed as form_calculations_changed, flags as field_flags,
};
pub use pdfcraft_a11y as a11y;
pub use pdfcraft_edit::{BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable};
pub use pdfcraft_measure as measure;
pub use pdfcraft_xfa::Report as XfaLayout;
pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget};
pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message};
/// Comment geometry helpers (text-box line breaking) for frontends.
pub use pdfcraft_annot::appearance as annot_text;
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
pub use pdfcraft_annot::{
AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps,
ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad,
};
pub use pdfcraft_forms::detect;
pub use pdfcraft_optimize as optimize;
pub use pdfcraft_preflight as pdfa;
pub use pdfcraft_print as print;
pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet};
pub use pdfcraft_redact::patterns::{
MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list,
};
pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden};
pub use pdfcraft_sign as sign;
pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore};
pub use pdfcraft_xfdf::Format as DataFormat;
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
//! Other files to combine with or copy pages from: opening them, and why one can't be used.
use std::sync::Arc;
use crate::{EditError, SourceProblem};
/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all).
pub type CombineSource = (String, Arc<Vec<u8>>, Option<String>);
/// What a combine produced: the new PDF bytes, and the fields renamed to keep colliding names
/// separate (old fully-qualified name → new one), in order across the sources.
#[derive(Debug)]
pub struct Combined {
pub bytes: Arc<Vec<u8>>,
pub renamed_fields: Vec<(String, String)>,
}
/// Parse another PDF to copy pages from.
pub(crate) fn open_source(name: &str, bytes: &Arc<Vec<u8>>) -> Result<pdfcraft_cos::Document, EditError> {
open_source_with(name, bytes, None)
}
/// [`open_source`], authenticating with `password` (user or owner) for an encrypted file.
pub(crate) fn open_source_with(name: &str, bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<pdfcraft_cos::Document, EditError> {
source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}")))
}
fn source_document(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<pdfcraft_cos::Document, SourceProblem> {
match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) {
Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted),
Ok(Ok(d)) => Ok(d),
Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password),
Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword),
Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())),
Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())),
}
}
/// Whether `bytes` can be combined, opened with `password` (user or owner) if given, and if not
/// why: what Combine would refuse it for, so the Combine files list can say so before Combine
/// is pressed. The owner (permissions) password lifts the restriction on copying pages.
pub fn combine_source_check(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<(), SourceProblem> {
source_document(bytes, password).map(|_| ())
}
/// The number of pages of `bytes`, opened with `password` (user or owner) if given, whatever
/// its permissions allow; `None` when it can't be read.
pub fn source_page_count(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Option<usize> {
let doc = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)).ok()?.ok()?;
pdfcraft_organize::page_count(&doc).ok()
}
+42
View File
@@ -0,0 +1,42 @@
//! Test-only hooks: injected refresh panics and counts of the secondary page scans.
thread_local! {
static SECONDARY_SCAN_COUNTS: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) };
static REFRESH_PANICS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
pub(crate) fn panic_on_refresh(count: usize) {
REFRESH_PANICS.with(|n| n.set(count));
}
pub(crate) fn check_refresh_panic() {
REFRESH_PANICS.with(|n| {
let rem = n.get();
if rem > 0 {
n.set(rem - 1);
panic!("injected refresh panic");
}
});
}
pub(crate) fn reset_secondary_scan_counts() {
SECONDARY_SCAN_COUNTS.with(|counts| counts.set((0, 0)));
}
pub(crate) fn secondary_scan_counts() -> (usize, usize) {
SECONDARY_SCAN_COUNTS.with(std::cell::Cell::get)
}
pub(crate) fn record_marker_scan() {
SECONDARY_SCAN_COUNTS.with(|counts| {
let (markers, links) = counts.get();
counts.set((markers + 1, links));
});
}
pub(crate) fn record_link_scan() {
SECONDARY_SCAN_COUNTS.with(|counts| {
let (markers, links) = counts.get();
counts.set((markers, links + 1));
});
}
+3
View File
@@ -1,6 +1,9 @@
use std::sync::Arc;
use super::document::is_placeholder_title;
use super::edit::check_permission;
use super::*;
use pdfcraft_render::{Layer, LayerOp, OpenError, ReadingColors};
/// A minimal `n`-page document; page `i` shows "Page i+1".
pub(crate) fn fixture(n: usize) -> Vec<u8> {
+297
View File
@@ -0,0 +1,297 @@
//! A document's XFA form: laying it out, keeping its datasets and fields in step, and taking it out.
use crate::edit::EditCtx;
use crate::{Document, EditError, FieldValue, XfaLayout, guard, xfa};
/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances.
pub(crate) fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result<XfaLayout, String> {
let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?;
xfa_redraw(doc)?;
// The form's own values: one its field's font can't draw is drawn with `?` and reported.
report.warnings.extend(undrawn_field_warnings(doc));
Ok(report)
}
/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the
/// picture its signer's application drew.
pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> {
for f in pdfcraft_forms::fields(doc) {
if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) {
continue;
}
pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?;
}
Ok(())
}
/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font
/// can't draw (laying out an XFA form's own values).
pub(crate) fn undrawn_field_warnings(doc: &pdfcraft_cos::Document) -> Vec<String> {
pdfcraft_forms::undrawn_fields(doc)
.into_iter()
.map(|(name, c)| {
format!("form field {name:?} holds \"{}\" (U+{:04X}), which its font can't draw: it shows ? in its place", c.escape_debug(), u32::from(c))
})
.collect()
}
/// The form's fields as the XFA data layer wants them.
fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_xfa::FieldDatum> {
use pdfcraft_forms::FieldKind as K;
pdfcraft_forms::fields(doc)
.into_iter()
.map(|f| {
let data = match f.kind {
K::Text | K::Combo | K::List => pdfcraft_xfa::FieldData::Text(f.value.join("\n")),
K::CheckBox => pdfcraft_xfa::FieldData::Check(!f.value.is_empty()),
K::Radio => pdfcraft_xfa::FieldData::Radio(f.value.first().cloned()),
K::PushButton | K::Signature => pdfcraft_xfa::FieldData::None,
};
pdfcraft_xfa::FieldDatum { obj: f.obj, name: f.name, data }
})
.collect()
}
/// Keep the XFA datasets packet in step with the fields after an edit. Returns what could not
/// be written.
/// `datasets`: the stream this edit already wrote, replaced in place rather than added again
/// (and set to the one written).
/// `tpl`: the form's parsed template when the document has one cached (else it is parsed).
pub(crate) fn xfa_sync_datasets(
doc: &mut pdfcraft_cos::Document,
tpl: Option<&pdfcraft_xfa::model::Template>,
datasets: &mut Option<pdfcraft_cos::ObjRef>,
) -> Result<Vec<String>, String> {
let data = xfa_field_data(doc);
let r = pdfcraft_xfa::write_datasets_with(doc, tpl, &data, *datasets).map_err(|e| e.to_string())?;
if r.stream.is_some() {
*datasets = r.stream;
}
Ok(r.warnings)
}
/// What a document whose form script ran away is told.
pub(crate) const XFA_SCRIPTS_OFF: &str =
"A script of this form ran too long and was abandoned; the form's scripts are off for this document (reopen it to run them again)";
/// Most XFA warnings kept per document.
const MAX_XFA_WARNINGS: usize = 50;
/// Add `new` to `list` (no repeats, at most [`MAX_XFA_WARNINGS`]).
pub(crate) fn note_warnings(list: &mut Vec<String>, new: &[String]) {
for w in new {
if !list.contains(w) && list.len() < MAX_XFA_WARNINGS {
list.push(w.clone());
}
}
}
/// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or
/// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an
/// encoding this can't read.
pub(crate) fn decode_data_file(bytes: &[u8]) -> Option<Result<String, String>> {
if bytes.starts_with(b"%FDF") {
return None;
}
let declared = |text: &str| -> Option<String> {
let head = text.get(..text.len().min(256))?;
let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("<?xml")?;
let decl = decl.split_once("?>")?.0;
let enc = decl.split_once("encoding=")?.1;
let quote = enc.chars().next()?;
Some(enc.get(1..)?.split(quote).next()?.trim().to_ascii_lowercase())
};
if let Some((text, _)) = pdfcraft_xfa::packets::decode(bytes) {
match declared(&text).as_deref() {
None | Some("utf-8" | "utf8" | "utf-16" | "utf-16le" | "utf-16be" | "us-ascii" | "ascii") => return Some(Ok(text)),
Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") if !bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => {
// Each byte is the character it names (the 0x80–0x9F block of Windows-1252
// differs, and is rare in form data).
return Some(Ok(bytes.iter().map(|b| char::from(*b)).collect()));
}
Some(other) => return Some(Err(format!("the data file's encoding {other:?} is not supported (UTF-8, UTF-16 and ISO-8859-1 are)"))),
}
}
// Not valid UTF-8/UTF-16: Latin-1 by declaration, else refused.
let latin1: String = bytes.iter().map(|b| char::from(*b)).collect();
match declared(&latin1).as_deref() {
Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") => Some(Ok(latin1)),
_ => Some(Err("the data file is not UTF-8, UTF-16 or ISO-8859-1 text".into())),
}
}
/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed,
/// buttons that did nothing but run scripts, and fields whose value differs from the form's
/// data (data PdfCraft could not read into them, now lost).
pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> {
let fields = pdfcraft_forms::fields(doc);
let data = xfa_field_data(doc);
let differ = pdfcraft_xfa::read_values(doc, &data)
.into_iter()
.filter(|(name, value)| {
let Some(f) = fields.iter().find(|f| f.name == *name) else { return false };
match value {
pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(),
pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on,
pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(),
pdfcraft_xfa::FieldData::None => false,
}
})
.count();
let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?;
cx.xfa_removed.extend(r.notes());
if differ > 0 {
cx.xfa_removed.push(format!(
"{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone"
));
}
Ok(())
}
/// Keep the document's XFA state in step with its file after an edit: a script may have laid
/// the form out again (rows added, subforms shown), an edit may have taken the XFA out
/// (flattened, or made static), or an undo put it back.
pub(crate) fn sync_xfa_state(doc: &mut Document) {
let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return };
match pdfcraft_xfa::existing_layout(cos) {
Some(report) => {
if doc.xfa.is_none() {
// Back after an undo: the XFA's own warnings apply again, the strip's don't.
doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX));
if doc.xfa_template.is_none() && !doc.xfa_scripts_off {
doc.xfa_template = xfa::template(cos);
}
}
doc.xfa = Some(report);
}
None => {
doc.xfa = None;
doc.xfa_template = None;
// Only what the strip said stays (the XFA's own warnings no longer apply).
doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX));
}
}
}
/// How the warnings of a strip start, so they can be told from the XFA's own.
pub(crate) const XFA_REMOVED_PREFIX: &str = "XFA removed: ";
/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document),
/// rather than XFDF, Acrobat's `<fields>` XML or a table? By its root element.
pub(crate) fn is_xfa_data_file(text: &str) -> bool {
let text = text.get(..text.len().min(65_536)).unwrap_or(text);
let mut rest = text.trim_start_matches('\u{feff}').trim_start();
// Past the XML declaration, comments and processing instructions.
let mut guard = 0;
while guard < 100 {
guard += 1;
if let Some(r) = rest.strip_prefix("<?") {
rest = r.split_once("?>").map_or("", |(_, r)| r).trim_start();
} else if let Some(r) = rest.strip_prefix("<!--") {
rest = r.split_once("-->").map_or("", |(_, r)| r).trim_start();
} else if let Some(r) = rest.strip_prefix("<!") {
rest = r.split_once('>').map_or("", |(_, r)| r).trim_start();
} else {
break;
}
}
let Some(tag) = rest.strip_prefix('<') else { return false };
let name = tag.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or("");
let local = name.rsplit(':').next().unwrap_or(name);
!local.is_empty() && !matches!(local, "xfdf" | "fields" | "html" | "body")
}
/// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows.
pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> {
match pdfcraft_xfa::image::kind(bytes)? {
pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"),
pdfcraft_xfa::image::Kind::Png => Some("image/png"),
pdfcraft_xfa::image::Kind::Gif => Some("image/gif"),
pdfcraft_xfa::image::Kind::Bmp => Some("image/bmp"),
pdfcraft_xfa::image::Kind::Tiff => Some("image/tiff"),
}
}
/// After the XFA data changed outside the fields (an import, a picture): a laid-out form is
/// laid out again from the data (then, when `calculate`, its calculations run), and the
/// fields take the data's values.
pub(crate) fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> {
if let Some(tpl) = cx.xfa.clone() {
let mut run =
xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale };
let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run))
.unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})")));
cx.xfa_datasets = run.datasets;
cx.xfa_ran_away |= run.ran_away;
if let Err(e) = r {
cx.xfa_out.errors.push(e);
}
}
let taken = xfa_values_from_datasets(doc).map_err(EditError::Write)?;
if !taken.is_empty() {
cx.xfa_out.console.push(format!("{} field value(s) taken from the imported data", taken.len()));
}
// Not laid out again (a static form, or scripts off): fields the data no longer holds are
// emptied, so the data replaced is the data shown.
if cx.xfa.is_none() && calculate {
let data = xfa_field_data(doc);
let present: std::collections::HashSet<String> = pdfcraft_xfa::read_values(doc, &data).into_iter().map(|(n, _)| n).collect();
for f in pdfcraft_forms::fields(doc) {
if present.contains(&f.name) || f.value.is_empty() {
continue;
}
use pdfcraft_forms::FieldKind as K;
let empty = match f.kind {
K::Text => FieldValue::Text(String::new()),
K::CheckBox => FieldValue::Check(false),
K::Radio => FieldValue::Radio(None),
K::Combo | K::List => FieldValue::Choice(Vec::new()),
K::PushButton | K::Signature => continue,
};
if let Err(e) = pdfcraft_forms::set_value(doc, &f.name, &empty) {
cx.xfa_out.errors.push(format!("{}: not emptied by the import: {e}", f.name));
}
}
}
Ok(())
}
/// Give the fields the values the XFA datasets hold (a form filled in another viewer). Returns
/// the names of the fields that changed.
pub(crate) fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result<Vec<String>, String> {
let data = xfa_field_data(doc);
let fields = pdfcraft_forms::fields(doc);
// The first field of each name, as a lookup by name would find it.
let mut by_name = std::collections::HashMap::new();
for f in &fields {
by_name.entry(f.name.as_str()).or_insert(f);
}
let mut updates = Vec::new();
for (name, value) in pdfcraft_xfa::read_values(doc, &data) {
let Some(f) = by_name.get(name.as_str()) else { continue };
let new = match value {
pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => {
// Saved values, one per line for a multi-select list; shown text stands for
// its saved value (an older viewer may have written it).
let saved = |v: &str| f.options.iter().find(|(e, s)| e == v || s == v).map_or(v.to_string(), |(e, _)| e.clone());
let picked: Vec<String> = if f.has(pdfcraft_forms::flags::MULTI_SELECT) {
t.lines().map(str::trim).filter(|l| !l.is_empty()).map(saved).collect()
} else {
vec![saved(t.trim())]
};
(f.value != picked).then_some(FieldValue::Choice(picked))
}
pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)),
pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)),
pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)),
pdfcraft_xfa::FieldData::None => None,
};
if let Some(v) = new {
updates.push((name, v));
}
}
// All at once: one by one, each value would read the whole form again, which took minutes
// on a form with thousands of fields.
let Ok(results) = pdfcraft_forms::set_values(doc, &updates) else { return Ok(Vec::new()) };
Ok(updates.into_iter().zip(results).filter(|(_, r)| r.is_ok()).map(|((name, _), _)| name).collect())
}
+42 -25
View File
@@ -260,6 +260,47 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) ->
Ok(Some(Object::Ref(doc.add(Object::Dict(d)))))
}
/// Rewrite one trigger of a widget to `new` (`None` clears it), given the `old` value read from
/// the widget. `aa` and `a_entry` are the widget's `/AA` dict and `/A` entry, updated in place.
#[allow(clippy::too_many_arguments)]
fn replace_trigger(
doc: &mut Document,
pages: &[pdfcraft_cos::ObjRef],
scripts: &mut pdfcraft_cos::DecodeBudget,
t: Trigger,
new: Option<&FieldAction>,
old: Option<Object>,
aa: &mut Dict,
a_entry: &mut Option<Object>,
) -> Result<(), FormError> {
let old_action = old.as_ref().and_then(|o| read(doc, o, pages, scripts));
if new.is_some() && new == old_action.as_ref() {
return Ok(());
}
// The list shows a trigger's first action: replacing it keeps the ones chained after it.
let next = new.and(old).and_then(|o| doc.resolve(&o).as_dict().and_then(|d| d.get(b"Next").cloned()));
let obj = match new {
Some(a) => write(doc, a, pages)?,
None => None,
};
if let (Some(Object::Ref(r)), Some(next)) = (&obj, next) {
doc.update_dict(*r, |d| d.set(b"Next".to_vec(), next))?;
}
match t.key() {
None => {
*a_entry = obj;
aa.remove(b"U");
}
Some(k) => match obj {
Some(o) => aa.set(k.to_vec(), o),
None => {
aa.remove(k);
}
},
}
Ok(())
}
/// Replace field `name`'s actions (on every widget) with `actions`; triggers not listed lose
/// theirs. `Other` actions, and actions that read the same as before, are left as they are (with
/// their chain and the keys this module doesn't read); a replaced action keeps its chain.
@@ -281,31 +322,7 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi
None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()),
Some(k) => aa.get(k).cloned(),
};
let old_action = old.as_ref().and_then(|o| read(doc, o, &pages, &mut scripts));
if new.is_some() && new == old_action.as_ref() {
continue;
}
// The list shows a trigger's first action: replacing it keeps the ones chained after it.
let next = new.and(old).and_then(|o| doc.resolve(&o).as_dict().and_then(|d| d.get(b"Next").cloned()));
let obj = match new {
Some(a) => write(doc, a, &pages)?,
None => None,
};
if let (Some(Object::Ref(r)), Some(next)) = (&obj, next) {
doc.update_dict(*r, |d| d.set(b"Next".to_vec(), next))?;
}
match t.key() {
None => {
a_entry = obj;
aa.remove(b"U");
}
Some(k) => match obj {
Some(o) => aa.set(k.to_vec(), o),
None => {
aa.remove(k);
}
},
}
replace_trigger(doc, &pages, &mut scripts, t, new, old, &mut aa, &mut a_entry)?;
}
doc.update_dict(w.obj, |d| {
match &a_entry {
+6 -1
View File
@@ -477,6 +477,7 @@ fn content_draws(doc: &Document, content: &[Vec<u8>], xo: &Dict, name: &[u8], de
/// left as it is for them. Retired properties are likewise unlinked only when none of the
/// holder's pages or inheriting forms names them. Fails closed when use cannot be determined.
pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_properties: &[Object]) -> Result<(), RedactError> {
crate::type3::unlink_type3(doc)?;
if doomed.is_empty() && retired_properties.is_empty() {
return Ok(());
}
@@ -677,7 +678,7 @@ fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Option<Vec<Op
Some(ops)
}
fn resource_ops(doc: &Document, res: &Dict, content: &[Vec<u8>]) -> Result<Vec<Op>, CosError> {
pub(crate) fn resource_ops(doc: &Document, res: &Dict, content: &[Vec<u8>]) -> Result<Vec<Op>, CosError> {
let parsed = Pieces::join(content).parse();
if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 {
return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect property resources".into() });
@@ -1359,6 +1360,10 @@ fn xobject(
out.error = Some(error);
return None;
}
if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut cleaned_res, content) {
out.error = Some(error);
return None;
}
}
if cleaned_res != res {
dict.set(b"Resources".to_vec(), Object::Dict(cleaned_res));
+3
View File
@@ -27,6 +27,8 @@ pub mod sanitize;
mod tags;
#[cfg(test)]
mod tests;
pub(crate) mod type3;
pub(crate) mod type3_tables;
use interp::{Mode, Scope, process};
@@ -493,6 +495,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
pi,
)?;
retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?);
type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?;
doc.update_dict(page.obj, |d| {
d.set(b"Contents".to_vec(), Object::Array(new_list));
d.set(b"Resources".to_vec(), Object::Dict(res));
+6 -1
View File
@@ -424,6 +424,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
res.set(b"XObject".to_vec(), Object::Dict(xo));
}
retired_properties.extend(crate::interp::prune_properties(doc, &mut res, &page_content)?);
crate::type3::prune_type3_from_resources(doc, &mut res, &page_content)?;
doc.update_dict(page.obj, |d| {
d.set(b"Contents".to_vec(), Object::Array(new_list));
d.set(b"Resources".to_vec(), Object::Dict(res));
@@ -443,7 +444,11 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
}
// What a page no longer draws must not stay reachable through an inherited or shared
// /Resources either.
crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?;
// Only a removal rewrites the document; a count on a clone has nothing to unlink, and a
// cleanup error there would be mistaken for "nothing to remove".
if write {
crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?;
}
Ok((total, retained_off_layers))
}
@@ -105,3 +105,196 @@ fn an_image_another_page_draws_through_a_form_without_resources_is_kept() {
assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1);
assert!(full_save_holds(&doc, b"IMAGESECRET"), "the second page still draws the image through its form");
}
/// A Type 3 font whose displayed glyphs are redacted has its unused glyph programs unlinked from
/// /CharProcs and exclusive graphical resources pruned, so they do not survive in a full save (F-011).
#[test]
fn redacted_type3_glyph_and_exclusive_resources_do_not_survive_full_save() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(),
// Text showing AB with Type 3 font /T3
stream("", b"BT /T3 10 Tf 10 100 Td (AB) Tj ET"),
b"<< /Font << /T3 6 0 R >> >>".to_vec(),
// Type 3 font with /CharProcs << /A 7 0 R /B 8 0 R >> and /Resources with images for both
b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 7 0 R /B 8 0 R >> /Resources << /XObject << /ImA 9 0 R /ImB 10 0 R >> >> >>".to_vec(),
stream("", b"/ImA Do % SECRET_GLYPH_A_PAYLOAD"),
stream("", b"/ImB Do % SURVIVING_GLYPH_B_PAYLOAD"),
stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_A"),
stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBLIC_IMAGE_B"),
]);
// Redaction mark covers only 'A' (x: 10..15, y: 95..110)
mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], "");
assert!(full_save_holds(&doc, b"SECRET_GLYPH_A_PAYLOAD"));
assert!(full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_A"));
assert!(full_save_holds(&doc, b"SURVIVING_GLYPH_B_PAYLOAD"));
assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B"));
let report = apply(&mut doc, None).unwrap();
assert_eq!(report.glyphs, 1);
// After redaction, unreferenced glyph A and its exclusive image A must not survive full save
assert!(!full_save_holds(&doc, b"SECRET_GLYPH_A_PAYLOAD"), "redacted glyph A program survived");
assert!(!full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_A"), "exclusive image for redacted glyph A survived");
// Surviving glyph B and its image B must survive
assert!(full_save_holds(&doc, b"SURVIVING_GLYPH_B_PAYLOAD"), "surviving glyph B was lost");
assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B"), "surviving glyph B image was lost");
// Reopen and inspect the Type 3 font dictionary
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
let saved = Document::open(Arc::new(bytes)).unwrap();
let page = pdfcraft_model::pages(&saved).swap_remove(0);
let res = saved.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
let fonts = saved.resolve(res.get(b"Font").unwrap()).as_dict().unwrap().clone();
let t3 = saved.resolve(fonts.get(b"T3").unwrap()).as_dict().unwrap().clone();
let procs = saved.resolve(t3.get(b"CharProcs").unwrap()).as_dict().unwrap().clone();
assert!(!procs.contains(b"A"), "CharProcs still contains unreferenced glyph A");
assert!(procs.contains(b"B"), "CharProcs lost surviving glyph B");
let t3_res = saved.resolve(t3.get(b"Resources").unwrap()).as_dict().unwrap().clone();
let t3_xo = saved.resolve(t3_res.get(b"XObject").unwrap()).as_dict().unwrap().clone();
assert!(!t3_xo.contains(b"ImA"), "Type 3 font resources still contains exclusive image ImA");
assert!(t3_xo.contains(b"ImB"), "Type 3 font resources lost surviving image ImB");
}
/// When all glyphs of a Type 3 font are redacted, the entire font is removed from Resources (F-011).
#[test]
fn all_redacted_type3_glyphs_cause_font_removal_from_resources() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(),
stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"),
b"<< /Font << /T3 6 0 R >> >>".to_vec(),
b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [500] /Encoding << /Differences [65 /A] >> /CharProcs << /A 7 0 R >> /Resources << /XObject << /ImA 8 0 R >> >> >>".to_vec(),
stream("", b"/ImA Do % SECRET_GLYPH_ONLY_PAYLOAD"),
stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_ONLY"),
]);
mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], "");
let report = apply(&mut doc, None).unwrap();
assert_eq!(report.glyphs, 1);
assert!(!full_save_holds(&doc, b"SECRET_GLYPH_ONLY_PAYLOAD"));
assert!(!full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_ONLY"));
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
let saved = Document::open(Arc::new(bytes)).unwrap();
let page = pdfcraft_model::pages(&saved).swap_remove(0);
if let Some(res_val) = page.dict.get(b"Resources")
&& let Some(res) = saved.resolve(res_val).as_dict()
&& let Some(fonts_val) = res.get(b"Font")
{
let fonts = saved.resolve(fonts_val).as_dict().unwrap().clone();
assert!(!fonts.contains(b"T3"), "Font /T3 was not removed from Resources");
}
}
/// In shared Type 3 fonts across multiple pages, glyphs used on another page are preserved (F-011).
#[test]
fn shared_type3_font_preserves_glyphs_used_by_another_page() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(),
// Page 1: draws 'A' (redacted)
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Resources 7 0 R >>".to_vec(),
// Page 2: draws 'B' (survives)
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Resources 7 0 R >>".to_vec(),
stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"),
stream("", b"BT /T3 10 Tf 10 100 Td (B) Tj ET"),
// Shared Resources 7 0 R
b"<< /Font << /T3 8 0 R >> >>".to_vec(),
// Shared Type 3 font 8 0 R
b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 9 0 R /B 10 0 R >> >>".to_vec(),
stream("", b"% SECRET_PAGE1_GLYPH_A"),
stream("", b"% PUBLIC_PAGE2_GLYPH_B"),
]);
// Redact only Page 1's 'A'
mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], "");
let report = apply(&mut doc, None).unwrap();
assert_eq!(report.glyphs, 1);
// Glyph A was removed from Page 1 and used nowhere else -> unlinked!
assert!(!full_save_holds(&doc, b"SECRET_PAGE1_GLYPH_A"));
// Glyph B is still used on Page 2 -> kept!
assert!(full_save_holds(&doc, b"PUBLIC_PAGE2_GLYPH_B"));
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
let saved = Document::open(Arc::new(bytes)).unwrap();
let pages = pdfcraft_model::pages(&saved);
let p2_res = saved.resolve(pages[1].dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
let fonts = saved.resolve(p2_res.get(b"Font").unwrap()).as_dict().unwrap().clone();
let t3 = saved.resolve(fonts.get(b"T3").unwrap()).as_dict().unwrap().clone();
let procs = saved.resolve(t3.get(b"CharProcs").unwrap()).as_dict().unwrap().clone();
assert!(!procs.contains(b"A"));
assert!(procs.contains(b"B"));
}
/// Fails closed when Type 3 font cleanup cannot be safely established (F-011).
#[test]
fn unparseable_type3_font_encoding_fails_closed() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(),
stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"),
b"<< /Font << /T3 6 0 R >> >>".to_vec(),
// Unsupported encoding /UnknownCustomEncoding without Differences
b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [500] /Encoding /UnknownCustomEncoding /CharProcs << /A 7 0 R >> >>".to_vec(),
stream("", b"% SECRET_PAYLOAD_UNPARSEABLE"),
]);
mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], "");
let res = apply(&mut doc, None);
assert!(res.is_err(), "redaction must fail closed when Type 3 cleanup cannot be established: {res:?}");
}
/// A Type 3 font selected through an ExtGState /Font entry is a use of the font for pruning:
/// its redacted glyph program does not survive a full save (F-011).
#[test]
fn type3_font_set_through_extgstate_has_its_redacted_glyph_removed() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(),
stream("", b"/GS1 gs BT 10 100 Td (A) Tj ET"),
b"<< /Font << /T3 6 0 R >> /ExtGState << /GS1 8 0 R >> >>".to_vec(),
b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [500] /Encoding << /Differences [65 /A] >> /CharProcs << /A 7 0 R >> >>".to_vec(),
stream("", b"% SECRET_EXTGSTATE_GLYPH_PAYLOAD"),
b"<< /Type /ExtGState /Font [6 0 R 10] >>".to_vec(),
]);
mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], "");
let report = apply(&mut doc, None).unwrap();
assert_eq!(report.glyphs, 1);
assert!(!full_save_holds(&doc, b"SECRET_EXTGSTATE_GLYPH_PAYLOAD"), "redacted glyph program survived");
}
/// A Type 3 font used only inside a Form XObject is found by the scan and its redacted glyph
/// program does not survive a full save (F-011).
#[test]
fn type3_font_used_in_a_form_xobject_has_its_redacted_glyph_removed() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(),
stream("", b"/Fm Do"),
b"<< /XObject << /Fm 8 0 R >> >>".to_vec(),
b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [500] /Encoding << /Differences [65 /A] >> /CharProcs << /A 7 0 R >> >>".to_vec(),
stream("", b"% SECRET_FORM_GLYPH_PAYLOAD"),
stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 9 0 R", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"),
b"<< /Font << /T3 6 0 R >> >>".to_vec(),
]);
mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], "");
let report = apply(&mut doc, None).unwrap();
assert_eq!(report.glyphs, 1);
assert!(!full_save_holds(&doc, b"SECRET_FORM_GLYPH_PAYLOAD"), "redacted glyph program survived");
}
+692
View File
@@ -0,0 +1,692 @@
//! Unlink unused Type 3 font glyph programs and prune exclusive graphical resources (F-011).
use std::collections::{HashMap, HashSet};
use pdfcraft_content::Pieces;
use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object};
use crate::{RedactError, page_streams};
const MAX_DEPTH: usize = 64;
const MAX_HOLDERS: usize = 1_000_000;
fn res_dict(doc: &Document, res: &Dict, key: &[u8]) -> Dict {
res.get(key).and_then(|o| doc.resolve(o).as_dict().cloned()).unwrap_or_default()
}
/// Identifies a font in the document. Only fonts that are indirect objects are cleaned: a Type 3
/// font held directly in a resource dictionary fails closed (see [`unlink_type3`]), because its
/// glyph programs could not be told apart or rewritten reliably.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(crate) enum FontKey {
Ref(ObjRef),
}
/// Build the mapping from character code (0..=255) to glyph name in a Type 3 font.
/// Fails closed if the encoding cannot be safely resolved or parsed.
pub(crate) fn type3_code_to_glyph(doc: &Document, font_dict: &Dict) -> Result<[Option<Vec<u8>>; 256], CosError> {
let Some(enc_obj) = font_dict.get(b"Encoding") else {
return Err(CosError::Syntax { offset: 0, detail: "redaction could not establish safe Type 3 font cleanup: missing Encoding".into() });
};
let resolved_enc = doc.resolve(enc_obj);
let mut differences = HashMap::new();
let base_name: Option<Vec<u8>>;
match &*resolved_enc {
Object::Dict(enc_dict) => {
if let Some(diffs) = enc_dict.get(b"Differences").and_then(|d| doc.resolve(d).as_array().cloned()) {
let mut cur_code = 0i64;
for item in &diffs {
match &*doc.resolve(item) {
Object::Int(n) => cur_code = *n,
Object::Name(name) => {
if let Ok(c) = u8::try_from(cur_code) {
differences.insert(c, name.clone());
}
cur_code = cur_code.saturating_add(1);
}
_ => {}
}
}
}
if let Some(base) = enc_dict.name(b"BaseEncoding") {
base_name = Some(base.to_vec());
} else {
// ISO 32000 §9.6.6.1: default base encoding for Type 3 fonts is StandardEncoding.
base_name = Some(b"StandardEncoding".to_vec());
}
}
Object::Name(name) => {
base_name = Some(name.clone());
}
_ => {
return Err(CosError::Syntax {
offset: 0,
detail: "redaction could not establish safe Type 3 font cleanup: invalid Encoding object".into(),
});
}
}
let base_table: Option<&'static [Option<&'static [u8]>; 256]> = match base_name.as_deref() {
Some(b"StandardEncoding") => Some(&crate::type3_tables::STANDARD),
Some(b"WinAnsiEncoding") => Some(&crate::type3_tables::WIN_ANSI),
Some(b"MacRomanEncoding") => Some(&crate::type3_tables::MAC_ROMAN),
Some(b"MacExpertEncoding") => Some(&crate::type3_tables::MAC_EXPERT),
Some(other) => {
return Err(CosError::Syntax {
offset: 0,
detail: format!(
"redaction could not establish safe Type 3 font cleanup: unsupported BaseEncoding: {}",
String::from_utf8_lossy(other)
),
});
}
None => None,
};
let mut map: [Option<Vec<u8>>; 256] = std::array::from_fn(|_| None);
for (code, entry) in map.iter_mut().enumerate() {
let c = code as u8;
if let Some(diff_glyph) = differences.get(&c) {
*entry = Some(diff_glyph.clone());
} else if let Some(table) = base_table
&& let Some(base_glyph) = table[code]
{
*entry = Some(base_glyph.to_vec());
}
}
Ok(map)
}
fn resolve_font_in_res(doc: &Document, res: &Dict, font_name: &[u8]) -> Option<(FontKey, Dict)> {
let fonts = res_dict(doc, res, b"Font");
let font_obj = fonts.get(font_name)?;
// A direct Type 3 font is refused during discovery, so none reaches the scan.
let font_ref = font_obj.as_ref()?;
let dict = doc.resolve(font_obj).as_dict()?.clone();
(dict.name(b"Subtype") == Some(b"Type3")).then_some((FontKey::Ref(font_ref), dict))
}
fn resolve_extgstate_font(doc: &Document, res: &Dict, gs_name: &[u8]) -> Option<(FontKey, Dict)> {
let extg = res_dict(doc, res, b"ExtGState");
let state = doc.resolve(extg.get(gs_name)?).as_dict().cloned()?;
let font_entry = state.get(b"Font").and_then(|f| doc.resolve(f).as_array().cloned())?;
let font_obj = font_entry.first()?;
let font_ref = font_obj.as_ref()?;
let dict = doc.resolve(font_obj).as_dict()?.clone();
(dict.name(b"Subtype") == Some(b"Type3")).then_some((FontKey::Ref(font_ref), dict))
}
#[derive(Default)]
struct UsedInGlyphs {
xobjects: HashSet<Vec<u8>>,
colorspaces: HashSet<Vec<u8>>,
patterns: HashSet<Vec<u8>>,
shadings: HashSet<Vec<u8>>,
extgstates: HashSet<Vec<u8>>,
fonts: HashSet<Vec<u8>>,
properties: HashSet<Vec<u8>>,
}
fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Result<(), CosError> {
let parsed = pdfcraft_content::parse(data);
if parsed.skipped > 0 {
return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect glyph stream syntax".into() });
}
for op in parsed.ops {
match op.op.as_slice() {
b"Do" => {
if let Some(n) = op.last_name() {
used.xobjects.insert(n.to_vec());
}
}
b"cs" | b"CS" => {
if let Some(n) = op.name(0) {
used.colorspaces.insert(n.to_vec());
}
}
b"scn" | b"SCN" => {
if let Some(n) = op.last_name() {
used.patterns.insert(n.to_vec());
}
}
b"sh" => {
if let Some(n) = op.last_name() {
used.shadings.insert(n.to_vec());
}
}
b"gs" => {
if let Some(n) = op.name(0) {
used.extgstates.insert(n.to_vec());
}
}
b"Tf" => {
if let Some(n) = op.name(0) {
used.fonts.insert(n.to_vec());
}
}
b"BDC" | b"DP" => {
if let Some(n) = op.name(1) {
used.properties.insert(n.to_vec());
}
}
_ => {}
}
}
Ok(())
}
fn prune_type3_font_resources(doc: &Document, font_res: &mut Dict, used: &UsedInGlyphs) -> bool {
let mut changed = false;
macro_rules! prune_category {
($cat:expr, $used_set:expr) => {
if let Some(sub_obj) = font_res.get($cat) {
let mut sub = doc.resolve(sub_obj).as_dict().cloned().unwrap_or_default();
let names: Vec<Vec<u8>> = sub.iter().map(|(n, _)| n.to_vec()).collect();
let mut cat_changed = false;
for n in names {
if !$used_set.contains(&n) {
sub.remove(&n);
cat_changed = true;
}
}
if cat_changed {
changed = true;
if sub.is_empty() {
font_res.remove($cat);
} else {
font_res.set($cat.to_vec(), Object::Dict(sub));
}
}
}
};
}
prune_category!(b"XObject", used.xobjects);
prune_category!(b"ColorSpace", used.colorspaces);
prune_category!(b"Pattern", used.patterns);
prune_category!(b"Shading", used.shadings);
prune_category!(b"ExtGState", used.extgstates);
prune_category!(b"Font", used.fonts);
prune_category!(b"Properties", used.properties);
changed
}
struct ScanContext<'a> {
doc: &'a Document,
font_maps: &'a HashMap<FontKey, [Option<Vec<u8>>; 256]>,
used_glyphs: &'a mut HashMap<FontKey, HashSet<Vec<u8>>>,
/// A form scanned with its inherited font and the resources it draws through: the same form
/// drawn in another context is scanned again.
seen_forms: &'a mut HashSet<(ObjRef, Option<FontKey>, ObjRef)>,
}
fn scan_content_stream(
ctx: &mut ScanContext<'_>,
content: &[Vec<u8>],
holder_ref: ObjRef,
res: &Dict,
initial_font: Option<FontKey>,
depth: usize,
) -> Result<(), CosError> {
if depth > MAX_DEPTH {
return Err(CosError::Syntax { offset: 0, detail: "redaction exceeded max depth inspecting Type 3 font content".into() });
}
let joined = Pieces::join(content);
let parsed = joined.parse();
if parsed.skipped > 0 {
return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect content stream syntax".into() });
}
let mut stack: Vec<Option<FontKey>> = Vec::new();
let mut current_font = initial_font;
for op in parsed.ops {
match op.op.as_slice() {
b"q" => stack.push(current_font),
b"Q" => {
if let Some(saved) = stack.pop() {
current_font = saved;
}
}
b"Do" => {
if let Some(name) = op.last_name() {
scan_xobject(ctx, res, holder_ref, name, current_font, depth)?;
}
}
_ => apply_text_op(ctx, res, &op, &mut current_font),
}
}
Ok(())
}
/// Apply an operator that changes or uses the current font: `Tf`, `gs`, and the text-showing
/// operators, which record the glyphs they draw.
fn apply_text_op(ctx: &mut ScanContext<'_>, res: &Dict, op: &pdfcraft_content::Op, current_font: &mut Option<FontKey>) {
match op.op.as_slice() {
b"Tf" => {
if let Some(name) = op.name(0) {
*current_font = resolve_font_in_res(ctx.doc, res, name).map(|(k, _)| k);
}
}
b"gs" => {
if let Some(name) = op.name(0)
&& let Some((k, _)) = resolve_extgstate_font(ctx.doc, res, name)
{
*current_font = Some(k);
}
}
b"Tj" | b"'" | b"\"" => {
if let Some(s) = op.operands.last().and_then(Object::as_string) {
mark_shown(ctx, *current_font, &s.bytes);
}
}
b"TJ" => {
if let Some(arr) = op.operands.last().and_then(Object::as_array) {
for item in arr {
if let Some(s) = item.as_string() {
mark_shown(ctx, *current_font, &s.bytes);
}
}
}
}
_ => {}
}
}
/// Record every glyph of `font` that the shown string `bytes` uses.
fn mark_shown(ctx: &mut ScanContext<'_>, font: Option<FontKey>, bytes: &[u8]) {
let Some(fk) = font else { return };
let Some(map) = ctx.font_maps.get(&fk) else { return };
for b in bytes {
if let Some(g) = &map[*b as usize] {
ctx.used_glyphs.entry(fk).or_default().insert(g.clone());
}
}
}
/// Scan the Form XObject `name` drawn by a `Do` operator, once per (form, font, drawing holder).
fn scan_xobject(
ctx: &mut ScanContext<'_>,
res: &Dict,
holder_ref: ObjRef,
name: &[u8],
current_font: Option<FontKey>,
depth: usize,
) -> Result<(), CosError> {
let xo = res_dict(ctx.doc, res, b"XObject");
let Some(r) = xo.get(name).and_then(Object::as_ref) else { return Ok(()) };
let obj = ctx.doc.get(r);
let Object::Stream(s) = &*obj else { return Ok(()) };
if s.dict.name(b"Subtype") != Some(b"Form") {
return Ok(());
}
let form_own_res = s.dict.get(b"Resources").and_then(|ro| ctx.doc.resolve(ro).as_dict().cloned());
// A form with resources of its own draws through them; one without draws through its caller's.
let through = if form_own_res.is_some() { r } else { holder_ref };
if !ctx.seen_forms.insert((r, current_font, through)) {
return Ok(());
}
let data = s.decoded()?;
match form_own_res {
Some(form_res) => scan_content_stream(ctx, std::slice::from_ref(&data), r, &form_res, current_font, depth + 1),
None => scan_content_stream(ctx, std::slice::from_ref(&data), holder_ref, res, current_font, depth + 1),
}
}
/// The font object an ExtGState's `/Font` entry names (its first element), if any.
fn gs_font_object(doc: &Document, state_obj: &Object) -> Option<Object> {
let state = doc.resolve(state_obj).as_dict()?.clone();
let font_entry = doc.resolve(state.get(b"Font")?).as_array()?.clone();
font_entry.into_iter().next()
}
/// Prune Type 3 fonts from a rewritten page or form's `/Resources` dictionary if the rewritten
/// content no longer draws any text using that font name.
pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content: &[Vec<u8>]) -> Result<(), CosError> {
let mut fonts = res_dict(doc, res, b"Font");
if fonts.is_empty() {
return Ok(());
}
// The operators of forms that draw through these resources count too: dropping a font a child
// form still shows would leave its text without one.
let ops = crate::interp::resource_ops(doc, res, content)?;
let mut drawn_font_names = HashSet::new();
for op in &ops {
if op.is("Tf") {
if let Some(name) = op.name(0) {
drawn_font_names.insert(name.to_vec());
}
} else if op.is("gs")
&& let Some(name) = op.name(0)
{
let extg = res_dict(doc, res, b"ExtGState");
if let Some(font_obj) = extg.get(name).and_then(|state_obj| gs_font_object(doc, state_obj)) {
drawn_font_names.extend(
fonts
.iter()
.filter(|(_, fn_obj)| fn_obj.as_ref().is_some() && fn_obj.as_ref() == font_obj.as_ref())
.map(|(fn_name, _)| fn_name.to_vec()),
);
}
}
}
let mut changed = false;
let font_names: Vec<Vec<u8>> = fonts.iter().map(|(n, _)| n.to_vec()).collect();
for name in font_names {
if let Some(font_obj) = fonts.get(&name) {
let resolved = doc.resolve(font_obj);
if let Some(d) = resolved.as_dict()
&& d.name(b"Subtype") == Some(b"Type3")
&& !drawn_font_names.contains(&name)
{
fonts.remove(&name);
changed = true;
}
}
}
if changed {
if fonts.is_empty() {
res.remove(b"Font");
} else {
res.set(b"Font".to_vec(), Object::Dict(fonts));
}
}
Ok(())
}
/// Unlink unused Type 3 glyph programs from `CharProcs`, prune their exclusive resources from the
/// font's `/Resources`, and remove completely unused Type 3 fonts across all reachable resource
/// holders in the document. Fails closed if safe cleanup cannot be established.
pub(crate) fn unlink_type3(doc: &mut Document) -> Result<(), RedactError> {
let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else {
return Ok(());
};
// Find all page-tree nodes with /Resources of their own, and the pages that draw through them.
let mut holders: Vec<(ObjRef, Vec<ObjRef>)> = Vec::new();
let mut seen = HashSet::new();
let mut stack: Vec<(ObjRef, Option<usize>)> = vec![(top, None)];
while let Some((node, mut holder)) = stack.pop() {
if !seen.insert(node) || seen.len() > MAX_HOLDERS {
continue;
}
let obj = doc.get(node);
let Some(d) = obj.as_dict() else { continue };
if d.contains(b"Resources") {
holders.push((node, Vec::new()));
holder = Some(holders.len() - 1);
}
let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page"));
if is_pages {
let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
stack.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, holder)));
} else if let Some(h) = holder.and_then(|i| holders.get_mut(i)) {
h.1.push(node);
}
}
let form_holders = reachable_form_holders(doc, &holders)?;
// Discover all Type 3 fonts in holders and forms.
let mut type3_fonts: HashMap<FontKey, Dict> = HashMap::new();
for (node, _) in &holders {
if let Some(res) = doc.get(*node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) {
collect_type3_fonts(doc, &res, &mut type3_fonts)?;
}
}
for (_, form_res) in &form_holders {
collect_type3_fonts(doc, form_res, &mut type3_fonts)?;
}
if type3_fonts.is_empty() {
return Ok(());
}
// Build code-to-glyph mappings for each Type 3 font. Fails closed on error.
let mut font_maps: HashMap<FontKey, [Option<Vec<u8>>; 256]> = HashMap::new();
for (key, font_dict) in &type3_fonts {
let map = type3_code_to_glyph(doc, font_dict)?;
font_maps.insert(*key, map);
}
// Scan all page contents and reachable forms to discover surviving glyph usage.
let page_list = pdfcraft_model::pages(doc);
let mut used_glyphs: HashMap<FontKey, HashSet<Vec<u8>>> = HashMap::new();
let mut seen_drawn_forms = HashSet::new();
let mut ctx = ScanContext { doc, font_maps: &font_maps, used_glyphs: &mut used_glyphs, seen_forms: &mut seen_drawn_forms };
for (pi, p) in page_list.iter().enumerate() {
let (_, content) = page_streams(ctx.doc, &p.dict, pi)?;
let page_res = p
.dict
.get(b"Resources")
.and_then(|r| ctx.doc.resolve(r).as_dict().cloned())
.or_else(|| {
holders.iter().find(|(_, users)| users.contains(&p.obj)).and_then(|(h, _)| {
ctx.doc.get(*h).as_dict().and_then(|d| d.get(b"Resources")).and_then(|ro| ctx.doc.resolve(ro).as_dict().cloned())
})
})
.unwrap_or_default();
scan_content_stream(&mut ctx, &content, p.obj, &page_res, None, 0)?;
}
// Also scan any surviving form that wasn't reached by page streams directly.
for (form_ref, form_res) in &form_holders {
if !ctx.seen_forms.iter().any(|(form, _, _)| form == form_ref) {
let Object::Stream(s) = &*ctx.doc.get(*form_ref) else { continue };
let data = s.decoded()?;
scan_content_stream(&mut ctx, std::slice::from_ref(&data), *form_ref, form_res, None, 0)?;
}
}
// For each Type 3 font: unlink unused glyphs from CharProcs and prune exclusive resources.
for (font_key, font_dict) in &type3_fonts {
let surviving = used_glyphs.get(font_key).cloned().unwrap_or_default();
clean_type3_font(doc, &holders, &form_holders, font_key, font_dict, &surviving)?;
}
Ok(())
}
/// Every reachable Form XObject with resources of its own, with those resources, found by
/// walking the resources of the holders and of the forms they draw.
fn reachable_form_holders(doc: &Document, holders: &[(ObjRef, Vec<ObjRef>)]) -> Result<Vec<(ObjRef, Dict)>, CosError> {
let mut form_holders: Vec<(ObjRef, Dict)> = Vec::new();
let mut pending_res: Vec<Dict> = holders
.iter()
.filter_map(|(r, _)| doc.get(*r).as_dict().and_then(|d| d.get(b"Resources")).and_then(|ro| doc.resolve(ro).as_dict().cloned()))
.collect();
let mut seen_forms = HashSet::new();
while let Some(res) = pending_res.pop() {
for (_, value) in res_dict(doc, &res, b"XObject").iter() {
let Some(reference) = value.as_ref() else { continue };
if !seen_forms.insert(reference) {
continue;
}
if seen_forms.len() > MAX_HOLDERS {
return Err(CosError::Syntax { offset: 0, detail: "redaction exceeded max depth inspecting form resources".into() });
}
let object = doc.get(reference);
let Object::Stream(form) = &*object else { continue };
if form.dict.name(b"Subtype") != Some(b"Form") {
continue;
}
if let Some(r) = form.dict.get(b"Resources").and_then(|ro| doc.resolve(ro).as_dict().cloned()) {
form_holders.push((reference, r.clone()));
pending_res.push(r);
}
}
}
Ok(form_holders)
}
/// Record every Type 3 font named by a resource dictionary's `/Font` and `/ExtGState` entries.
/// A Type 3 font held directly in a resource dictionary can neither be named reliably nor have its
/// glyph programs rewritten, so redaction fails closed rather than leaving its glyphs unchecked.
fn collect_type3_fonts(doc: &Document, res: &Dict, t3_fonts: &mut HashMap<FontKey, Dict>) -> Result<(), CosError> {
let direct = || CosError::Syntax {
offset: 0,
detail: "redaction could not establish safe Type 3 font cleanup: a Type 3 font is not an indirect object".into(),
};
let mut consider = |font_obj: &Object| -> Result<(), CosError> {
if let Some(dict) = doc.resolve(font_obj).as_dict().cloned()
&& dict.name(b"Subtype") == Some(b"Type3")
{
let font_ref = font_obj.as_ref().ok_or_else(direct)?;
t3_fonts.entry(FontKey::Ref(font_ref)).or_insert(dict);
}
Ok(())
};
for (_, font_obj) in res_dict(doc, res, b"Font").iter() {
consider(font_obj)?;
}
for (_, state_obj) in res_dict(doc, res, b"ExtGState").iter() {
if let Some(font_obj) = gs_font_object(doc, state_obj) {
consider(&font_obj)?;
}
}
Ok(())
}
/// Unlink the glyph programs of one Type 3 font that no longer draw, then either remove the font
/// (when no glyph survives) or prune the resources only its surviving glyphs used.
fn clean_type3_font(
doc: &mut Document,
holders: &[(ObjRef, Vec<ObjRef>)],
form_holders: &[(ObjRef, Dict)],
font_key: &FontKey,
font_dict: &Dict,
surviving: &HashSet<Vec<u8>>,
) -> Result<(), RedactError> {
let Some(char_procs_val) = font_dict.get(b"CharProcs") else {
return Err(CosError::Syntax { offset: 0, detail: "redaction could not establish safe Type 3 font cleanup: missing CharProcs".into() }.into());
};
let mut char_procs = doc.resolve(char_procs_val).as_dict().cloned().ok_or_else(|| CosError::Syntax {
offset: 0,
detail: "redaction could not establish safe Type 3 font cleanup: CharProcs is not a dict".into(),
})?;
let mut procs_changed = false;
let glyph_names: Vec<Vec<u8>> = char_procs.iter().map(|(n, _)| n.to_vec()).collect();
for g in glyph_names {
if !surviving.contains(&g) {
char_procs.remove(&g);
procs_changed = true;
}
}
let FontKey::Ref(fr) = font_key;
if char_procs.is_empty() {
// The font has no surviving glyphs: clear its own glyph programs and resources, then
// remove font from /Font across all holders.
doc.update_dict(*fr, |d| {
d.remove(b"CharProcs");
d.remove(b"Resources");
})?;
return remove_font_from_holders(doc, holders, form_holders, font_key);
}
// A CharProcs object can be shared with another Type 3 font, so never rewrite it in place:
// give this font its own filtered dictionary instead.
if procs_changed {
doc.update_dict(*fr, |d| {
d.set(b"CharProcs".to_vec(), Object::Dict(char_procs.clone()));
})?;
}
// Clean exclusive resources from the Type 3 font's /Resources.
let Some(f_res_val) = font_dict.get(b"Resources") else { return Ok(()) };
let mut f_res = doc.resolve(f_res_val).as_dict().cloned().unwrap_or_default();
let mut used_in_glyphs = UsedInGlyphs::default();
for (_, proc_val) in char_procs.iter() {
let resolved = doc.resolve(proc_val);
let Object::Stream(s) = &*resolved else {
return Err(CosError::Syntax {
offset: 0,
detail: "redaction could not establish safe Type 3 font cleanup: CharProc is not a stream".into(),
}
.into());
};
let data = s.decoded()?;
collect_used_resources_in_stream(&data, &mut used_in_glyphs)?;
}
if prune_type3_font_resources(doc, &mut f_res, &used_in_glyphs) {
// The pruned dictionary goes into the font object: a font's /Resources object is
// often the page's own, and rewriting that would strip the page of its resources.
doc.update_dict(*fr, |d| {
if f_res.is_empty() {
d.remove(b"Resources");
} else {
d.set(b"Resources".to_vec(), Object::Dict(f_res.clone()));
}
})?;
}
Ok(())
}
fn remove_font_from_holders(
doc: &mut Document,
holders: &[(ObjRef, Vec<ObjRef>)],
form_holders: &[(ObjRef, Dict)],
target_key: &FontKey,
) -> Result<(), RedactError> {
let clean_res = |doc: &Document, res: &mut Dict| -> bool {
let mut fonts = res_dict(doc, res, b"Font");
let mut changed = false;
let names: Vec<Vec<u8>> = fonts.iter().map(|(n, _)| n.to_vec()).collect();
for n in names {
if let Some(val) = fonts.get(&n)
&& val.as_ref().map(FontKey::Ref) == Some(*target_key)
{
fonts.remove(&n);
changed = true;
}
}
if changed {
if fonts.is_empty() {
res.remove(b"Font");
} else {
res.set(b"Font".to_vec(), Object::Dict(fonts));
}
}
changed
};
for (node, _) in holders {
if let Some(mut res) = doc.get(*node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned())
&& clean_res(doc, &mut res)
{
doc.update_dict(*node, |d| {
d.set(b"Resources".to_vec(), Object::Dict(res));
})?;
}
}
for (form_ref, _) in form_holders {
let obj = doc.get(*form_ref);
if let Object::Stream(s) = &*obj
&& let Some(mut res) = s.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned())
&& clean_res(doc, &mut res)
{
doc.update_dict(*form_ref, |d| {
d.set(b"Resources".to_vec(), Object::Dict(res));
})?;
}
}
Ok(())
}
+775
View File
@@ -0,0 +1,775 @@
//! Predefined font encodings (ISO 32000 Annex D) mapping character codes to glyph names.
//! Each encoding lists only the codes it maps; every other code is unmapped.
type Entries = &'static [(u8, &'static [u8])];
const fn build(entries: Entries) -> [Option<&'static [u8]>; 256] {
let mut out: [Option<&'static [u8]>; 256] = [None; 256];
let mut i = 0;
while i < entries.len() {
out[entries[i].0 as usize] = Some(entries[i].1);
i += 1;
}
out
}
const STANDARD_ENTRIES: Entries = &[
(32, b"space"),
(33, b"exclam"),
(34, b"quotedbl"),
(35, b"numbersign"),
(36, b"dollar"),
(37, b"percent"),
(38, b"ampersand"),
(39, b"quoteright"),
(40, b"parenleft"),
(41, b"parenright"),
(42, b"asterisk"),
(43, b"plus"),
(44, b"comma"),
(45, b"hyphen"),
(46, b"period"),
(47, b"slash"),
(48, b"zero"),
(49, b"one"),
(50, b"two"),
(51, b"three"),
(52, b"four"),
(53, b"five"),
(54, b"six"),
(55, b"seven"),
(56, b"eight"),
(57, b"nine"),
(58, b"colon"),
(59, b"semicolon"),
(60, b"less"),
(61, b"equal"),
(62, b"greater"),
(63, b"question"),
(64, b"at"),
(65, b"A"),
(66, b"B"),
(67, b"C"),
(68, b"D"),
(69, b"E"),
(70, b"F"),
(71, b"G"),
(72, b"H"),
(73, b"I"),
(74, b"J"),
(75, b"K"),
(76, b"L"),
(77, b"M"),
(78, b"N"),
(79, b"O"),
(80, b"P"),
(81, b"Q"),
(82, b"R"),
(83, b"S"),
(84, b"T"),
(85, b"U"),
(86, b"V"),
(87, b"W"),
(88, b"X"),
(89, b"Y"),
(90, b"Z"),
(91, b"bracketleft"),
(92, b"backslash"),
(93, b"bracketright"),
(94, b"asciicircum"),
(95, b"underscore"),
(96, b"quoteleft"),
(97, b"a"),
(98, b"b"),
(99, b"c"),
(100, b"d"),
(101, b"e"),
(102, b"f"),
(103, b"g"),
(104, b"h"),
(105, b"i"),
(106, b"j"),
(107, b"k"),
(108, b"l"),
(109, b"m"),
(110, b"n"),
(111, b"o"),
(112, b"p"),
(113, b"q"),
(114, b"r"),
(115, b"s"),
(116, b"t"),
(117, b"u"),
(118, b"v"),
(119, b"w"),
(120, b"x"),
(121, b"y"),
(122, b"z"),
(123, b"braceleft"),
(124, b"bar"),
(125, b"braceright"),
(126, b"asciitilde"),
(161, b"exclamdown"),
(162, b"cent"),
(163, b"sterling"),
(164, b"fraction"),
(165, b"yen"),
(166, b"florin"),
(167, b"section"),
(168, b"currency"),
(169, b"quotesingle"),
(170, b"quotedblleft"),
(171, b"guillemotleft"),
(172, b"guilsinglleft"),
(173, b"guilsinglright"),
(174, b"fi"),
(175, b"fl"),
(177, b"endash"),
(178, b"dagger"),
(179, b"daggerdbl"),
(180, b"periodcentered"),
(182, b"paragraph"),
(183, b"bullet"),
(184, b"quotesinglbase"),
(185, b"quotedblbase"),
(186, b"quotedblright"),
(187, b"guillemotright"),
(188, b"ellipsis"),
(189, b"perthousand"),
(191, b"questiondown"),
(193, b"grave"),
(194, b"acute"),
(195, b"circumflex"),
(196, b"tilde"),
(197, b"macron"),
(198, b"breve"),
(199, b"dotaccent"),
(200, b"dieresis"),
(202, b"ring"),
(203, b"cedilla"),
(205, b"hungarumlaut"),
(206, b"ogonek"),
(207, b"caron"),
(208, b"emdash"),
(225, b"AE"),
(227, b"ordfeminine"),
(232, b"Lslash"),
(233, b"Oslash"),
(234, b"OE"),
(235, b"ordmasculine"),
(241, b"ae"),
(245, b"dotlessi"),
(248, b"lslash"),
(249, b"oslash"),
(250, b"oe"),
(251, b"germandbls"),
];
pub(crate) static STANDARD: [Option<&[u8]>; 256] = build(STANDARD_ENTRIES);
const WIN_ANSI_ENTRIES: Entries = &[
(32, b"space"),
(33, b"exclam"),
(34, b"quotedbl"),
(35, b"numbersign"),
(36, b"dollar"),
(37, b"percent"),
(38, b"ampersand"),
(39, b"quotesingle"),
(40, b"parenleft"),
(41, b"parenright"),
(42, b"asterisk"),
(43, b"plus"),
(44, b"comma"),
(45, b"hyphen"),
(46, b"period"),
(47, b"slash"),
(48, b"zero"),
(49, b"one"),
(50, b"two"),
(51, b"three"),
(52, b"four"),
(53, b"five"),
(54, b"six"),
(55, b"seven"),
(56, b"eight"),
(57, b"nine"),
(58, b"colon"),
(59, b"semicolon"),
(60, b"less"),
(61, b"equal"),
(62, b"greater"),
(63, b"question"),
(64, b"at"),
(65, b"A"),
(66, b"B"),
(67, b"C"),
(68, b"D"),
(69, b"E"),
(70, b"F"),
(71, b"G"),
(72, b"H"),
(73, b"I"),
(74, b"J"),
(75, b"K"),
(76, b"L"),
(77, b"M"),
(78, b"N"),
(79, b"O"),
(80, b"P"),
(81, b"Q"),
(82, b"R"),
(83, b"S"),
(84, b"T"),
(85, b"U"),
(86, b"V"),
(87, b"W"),
(88, b"X"),
(89, b"Y"),
(90, b"Z"),
(91, b"bracketleft"),
(92, b"backslash"),
(93, b"bracketright"),
(94, b"asciicircum"),
(95, b"underscore"),
(96, b"grave"),
(97, b"a"),
(98, b"b"),
(99, b"c"),
(100, b"d"),
(101, b"e"),
(102, b"f"),
(103, b"g"),
(104, b"h"),
(105, b"i"),
(106, b"j"),
(107, b"k"),
(108, b"l"),
(109, b"m"),
(110, b"n"),
(111, b"o"),
(112, b"p"),
(113, b"q"),
(114, b"r"),
(115, b"s"),
(116, b"t"),
(117, b"u"),
(118, b"v"),
(119, b"w"),
(120, b"x"),
(121, b"y"),
(122, b"z"),
(123, b"braceleft"),
(124, b"bar"),
(125, b"braceright"),
(126, b"asciitilde"),
(128, b"Euro"),
(130, b"quotesinglbase"),
(131, b"florin"),
(132, b"quotedblbase"),
(133, b"ellipsis"),
(134, b"dagger"),
(135, b"daggerdbl"),
(136, b"circumflex"),
(137, b"perthousand"),
(138, b"Scaron"),
(139, b"guilsinglleft"),
(140, b"OE"),
(142, b"Zcaron"),
(145, b"quoteleft"),
(146, b"quoteright"),
(147, b"quotedblleft"),
(148, b"quotedblright"),
(149, b"bullet"),
(150, b"endash"),
(151, b"emdash"),
(152, b"tilde"),
(153, b"trademark"),
(154, b"scaron"),
(155, b"guilsinglright"),
(156, b"oe"),
(158, b"zcaron"),
(159, b"Ydieresis"),
(160, b"space"),
(161, b"exclamdown"),
(162, b"cent"),
(163, b"sterling"),
(164, b"currency"),
(165, b"yen"),
(166, b"brokenbar"),
(167, b"section"),
(168, b"dieresis"),
(169, b"copyright"),
(170, b"ordfeminine"),
(171, b"guillemotleft"),
(172, b"logicalnot"),
(173, b"hyphen"),
(174, b"registered"),
(175, b"macron"),
(176, b"degree"),
(177, b"plusminus"),
(178, b"twosuperior"),
(179, b"threesuperior"),
(180, b"acute"),
(181, b"mu"),
(182, b"paragraph"),
(183, b"periodcentered"),
(184, b"cedilla"),
(185, b"onesuperior"),
(186, b"ordmasculine"),
(187, b"guillemotright"),
(188, b"onequarter"),
(189, b"onehalf"),
(190, b"threequarters"),
(191, b"questiondown"),
(192, b"Agrave"),
(193, b"Aacute"),
(194, b"Acircumflex"),
(195, b"Atilde"),
(196, b"Adieresis"),
(197, b"Aring"),
(198, b"AE"),
(199, b"Ccedilla"),
(200, b"Egrave"),
(201, b"Eacute"),
(202, b"Ecircumflex"),
(203, b"Edieresis"),
(204, b"Igrave"),
(205, b"Iacute"),
(206, b"Icircumflex"),
(207, b"Idieresis"),
(208, b"Eth"),
(209, b"Ntilde"),
(210, b"Ograve"),
(211, b"Oacute"),
(212, b"Ocircumflex"),
(213, b"Otilde"),
(214, b"Odieresis"),
(215, b"multiply"),
(216, b"Oslash"),
(217, b"Ugrave"),
(218, b"Uacute"),
(219, b"Ucircumflex"),
(220, b"Udieresis"),
(221, b"Yacute"),
(222, b"Thorn"),
(223, b"germandbls"),
(224, b"agrave"),
(225, b"aacute"),
(226, b"acircumflex"),
(227, b"atilde"),
(228, b"adieresis"),
(229, b"aring"),
(230, b"ae"),
(231, b"ccedilla"),
(232, b"egrave"),
(233, b"eacute"),
(234, b"ecircumflex"),
(235, b"edieresis"),
(236, b"igrave"),
(237, b"iacute"),
(238, b"icircumflex"),
(239, b"idieresis"),
(240, b"eth"),
(241, b"ntilde"),
(242, b"ograve"),
(243, b"oacute"),
(244, b"ocircumflex"),
(245, b"otilde"),
(246, b"odieresis"),
(247, b"divide"),
(248, b"oslash"),
(249, b"ugrave"),
(250, b"uacute"),
(251, b"ucircumflex"),
(252, b"udieresis"),
(253, b"yacute"),
(254, b"thorn"),
(255, b"ydieresis"),
];
pub(crate) static WIN_ANSI: [Option<&[u8]>; 256] = build(WIN_ANSI_ENTRIES);
const MAC_ROMAN_ENTRIES: Entries = &[
(32, b"space"),
(33, b"exclam"),
(34, b"quotedbl"),
(35, b"numbersign"),
(36, b"dollar"),
(37, b"percent"),
(38, b"ampersand"),
(39, b"quotesingle"),
(40, b"parenleft"),
(41, b"parenright"),
(42, b"asterisk"),
(43, b"plus"),
(44, b"comma"),
(45, b"hyphen"),
(46, b"period"),
(47, b"slash"),
(48, b"zero"),
(49, b"one"),
(50, b"two"),
(51, b"three"),
(52, b"four"),
(53, b"five"),
(54, b"six"),
(55, b"seven"),
(56, b"eight"),
(57, b"nine"),
(58, b"colon"),
(59, b"semicolon"),
(60, b"less"),
(61, b"equal"),
(62, b"greater"),
(63, b"question"),
(64, b"at"),
(65, b"A"),
(66, b"B"),
(67, b"C"),
(68, b"D"),
(69, b"E"),
(70, b"F"),
(71, b"G"),
(72, b"H"),
(73, b"I"),
(74, b"J"),
(75, b"K"),
(76, b"L"),
(77, b"M"),
(78, b"N"),
(79, b"O"),
(80, b"P"),
(81, b"Q"),
(82, b"R"),
(83, b"S"),
(84, b"T"),
(85, b"U"),
(86, b"V"),
(87, b"W"),
(88, b"X"),
(89, b"Y"),
(90, b"Z"),
(91, b"bracketleft"),
(92, b"backslash"),
(93, b"bracketright"),
(94, b"asciicircum"),
(95, b"underscore"),
(96, b"grave"),
(97, b"a"),
(98, b"b"),
(99, b"c"),
(100, b"d"),
(101, b"e"),
(102, b"f"),
(103, b"g"),
(104, b"h"),
(105, b"i"),
(106, b"j"),
(107, b"k"),
(108, b"l"),
(109, b"m"),
(110, b"n"),
(111, b"o"),
(112, b"p"),
(113, b"q"),
(114, b"r"),
(115, b"s"),
(116, b"t"),
(117, b"u"),
(118, b"v"),
(119, b"w"),
(120, b"x"),
(121, b"y"),
(122, b"z"),
(123, b"braceleft"),
(124, b"bar"),
(125, b"braceright"),
(126, b"asciitilde"),
(128, b"Adieresis"),
(129, b"Aring"),
(130, b"Ccedilla"),
(131, b"Eacute"),
(132, b"Ntilde"),
(133, b"Odieresis"),
(134, b"Udieresis"),
(135, b"aacute"),
(136, b"agrave"),
(137, b"acircumflex"),
(138, b"adieresis"),
(139, b"atilde"),
(140, b"aring"),
(141, b"ccedilla"),
(142, b"eacute"),
(143, b"egrave"),
(144, b"ecircumflex"),
(145, b"edieresis"),
(146, b"iacute"),
(147, b"igrave"),
(148, b"icircumflex"),
(149, b"idieresis"),
(150, b"ntilde"),
(151, b"oacute"),
(152, b"ograve"),
(153, b"ocircumflex"),
(154, b"odieresis"),
(155, b"otilde"),
(156, b"uacute"),
(157, b"ugrave"),
(158, b"ucircumflex"),
(159, b"udieresis"),
(160, b"dagger"),
(161, b"degree"),
(162, b"cent"),
(163, b"sterling"),
(164, b"section"),
(165, b"bullet"),
(166, b"paragraph"),
(167, b"germandbls"),
(168, b"registered"),
(169, b"copyright"),
(170, b"trademark"),
(171, b"acute"),
(172, b"dieresis"),
(174, b"AE"),
(175, b"Oslash"),
(177, b"plusminus"),
(180, b"yen"),
(181, b"mu"),
(187, b"ordfeminine"),
(188, b"ordmasculine"),
(190, b"ae"),
(191, b"oslash"),
(192, b"questiondown"),
(193, b"exclamdown"),
(194, b"logicalnot"),
(196, b"florin"),
(199, b"guillemotleft"),
(200, b"guillemotright"),
(201, b"ellipsis"),
(202, b"space"),
(203, b"Agrave"),
(204, b"Atilde"),
(205, b"Otilde"),
(206, b"OE"),
(207, b"oe"),
(208, b"endash"),
(209, b"emdash"),
(210, b"quotedblleft"),
(211, b"quotedblright"),
(212, b"quoteleft"),
(213, b"quoteright"),
(214, b"divide"),
(216, b"ydieresis"),
(217, b"Ydieresis"),
(218, b"fraction"),
(219, b"currency"),
(220, b"guilsinglleft"),
(221, b"guilsinglright"),
(222, b"fi"),
(223, b"fl"),
(224, b"daggerdbl"),
(225, b"periodcentered"),
(226, b"quotesinglbase"),
(227, b"quotedblbase"),
(228, b"perthousand"),
(229, b"Acircumflex"),
(230, b"Ecircumflex"),
(231, b"Aacute"),
(232, b"Edieresis"),
(233, b"Egrave"),
(234, b"Iacute"),
(235, b"Icircumflex"),
(236, b"Idieresis"),
(237, b"Igrave"),
(238, b"Oacute"),
(239, b"Ocircumflex"),
(241, b"Ograve"),
(242, b"Uacute"),
(243, b"Ucircumflex"),
(244, b"Ugrave"),
(245, b"dotlessi"),
(246, b"circumflex"),
(247, b"tilde"),
(248, b"macron"),
(249, b"breve"),
(250, b"dotaccent"),
(251, b"ring"),
(252, b"cedilla"),
(253, b"hungarumlaut"),
(254, b"ogonek"),
(255, b"caron"),
];
pub(crate) static MAC_ROMAN: [Option<&[u8]>; 256] = build(MAC_ROMAN_ENTRIES);
const MAC_EXPERT_ENTRIES: Entries = &[
(32, b"space"),
(33, b"exclamsmall"),
(34, b"Hungarumlautsmall"),
(35, b"centoldstyle"),
(36, b"dollaroldstyle"),
(37, b"dollarsuperior"),
(38, b"ampersandsmall"),
(39, b"Acutesmall"),
(40, b"parenleftsuperior"),
(41, b"parenrightsuperior"),
(42, b"twodotenleader"),
(43, b"onedotenleader"),
(44, b"comma"),
(45, b"hyphen"),
(46, b"period"),
(47, b"fraction"),
(48, b"zerooldstyle"),
(49, b"oneoldstyle"),
(50, b"twooldstyle"),
(51, b"threeoldstyle"),
(52, b"fouroldstyle"),
(53, b"fiveoldstyle"),
(54, b"sixoldstyle"),
(55, b"sevenoldstyle"),
(56, b"eightoldstyle"),
(57, b"nineoldstyle"),
(58, b"colon"),
(59, b"semicolon"),
(61, b"threequartersemdash"),
(63, b"questionsmall"),
(68, b"Ethsmall"),
(71, b"onequarter"),
(72, b"onehalf"),
(73, b"threequarters"),
(74, b"oneeighth"),
(75, b"threeeighths"),
(76, b"fiveeighths"),
(77, b"seveneighths"),
(78, b"onethird"),
(79, b"twothirds"),
(86, b"ff"),
(87, b"fi"),
(88, b"fl"),
(89, b"ffi"),
(90, b"ffl"),
(91, b"parenleftinferior"),
(93, b"parenrightinferior"),
(94, b"Circumflexsmall"),
(95, b"hypheninferior"),
(96, b"Gravesmall"),
(97, b"Asmall"),
(98, b"Bsmall"),
(99, b"Csmall"),
(100, b"Dsmall"),
(101, b"Esmall"),
(102, b"Fsmall"),
(103, b"Gsmall"),
(104, b"Hsmall"),
(105, b"Ismall"),
(106, b"Jsmall"),
(107, b"Ksmall"),
(108, b"Lsmall"),
(109, b"Msmall"),
(110, b"Nsmall"),
(111, b"Osmall"),
(112, b"Psmall"),
(113, b"Qsmall"),
(114, b"Rsmall"),
(115, b"Ssmall"),
(116, b"Tsmall"),
(117, b"Usmall"),
(118, b"Vsmall"),
(119, b"Wsmall"),
(120, b"Xsmall"),
(121, b"Ysmall"),
(122, b"Zsmall"),
(123, b"colonmonetary"),
(124, b"onefitted"),
(125, b"rupiah"),
(126, b"Tildesmall"),
(129, b"asuperior"),
(130, b"centsuperior"),
(135, b"Aacutesmall"),
(136, b"Agravesmall"),
(137, b"Acircumflexsmall"),
(138, b"Adieresissmall"),
(139, b"Atildesmall"),
(140, b"Aringsmall"),
(141, b"Ccedillasmall"),
(142, b"Eacutesmall"),
(143, b"Egravesmall"),
(144, b"Ecircumflexsmall"),
(145, b"Edieresissmall"),
(146, b"Iacutesmall"),
(147, b"Igravesmall"),
(148, b"Icircumflexsmall"),
(149, b"Idieresissmall"),
(150, b"Ntildesmall"),
(151, b"Oacutesmall"),
(152, b"Ogravesmall"),
(153, b"Ocircumflexsmall"),
(154, b"Odieresissmall"),
(155, b"Otildesmall"),
(156, b"Uacutesmall"),
(157, b"Ugravesmall"),
(158, b"Ucircumflexsmall"),
(159, b"Udieresissmall"),
(161, b"eightsuperior"),
(162, b"fourinferior"),
(163, b"threeinferior"),
(164, b"sixinferior"),
(165, b"eightinferior"),
(166, b"seveninferior"),
(167, b"Scaronsmall"),
(169, b"centinferior"),
(170, b"twoinferior"),
(172, b"Dieresissmall"),
(174, b"Caronsmall"),
(175, b"osuperior"),
(176, b"fiveinferior"),
(178, b"commainferior"),
(179, b"periodinferior"),
(180, b"Yacutesmall"),
(182, b"dollarinferior"),
(185, b"Thornsmall"),
(187, b"nineinferior"),
(188, b"zeroinferior"),
(189, b"Zcaronsmall"),
(190, b"AEsmall"),
(191, b"Oslashsmall"),
(192, b"questiondownsmall"),
(193, b"oneinferior"),
(194, b"Lslashsmall"),
(201, b"Cedillasmall"),
(207, b"OEsmall"),
(208, b"figuredash"),
(209, b"hyphensuperior"),
(214, b"exclamdownsmall"),
(216, b"Ydieresissmall"),
(218, b"onesuperior"),
(219, b"twosuperior"),
(220, b"threesuperior"),
(221, b"foursuperior"),
(222, b"fivesuperior"),
(223, b"sixsuperior"),
(224, b"sevensuperior"),
(225, b"ninesuperior"),
(226, b"zerosuperior"),
(228, b"esuperior"),
(229, b"rsuperior"),
(230, b"tsuperior"),
(233, b"isuperior"),
(234, b"ssuperior"),
(235, b"dsuperior"),
(241, b"lsuperior"),
(242, b"Ogoneksmall"),
(243, b"Brevesmall"),
(244, b"Macronsmall"),
(245, b"bsuperior"),
(246, b"nsuperior"),
(247, b"msuperior"),
(248, b"commasuperior"),
(249, b"periodsuperior"),
(250, b"Dotaccentsmall"),
(251, b"Ringsmall"),
];
pub(crate) static MAC_EXPERT: [Option<&[u8]>; 256] = build(MAC_EXPERT_ENTRIES);
+5 -2
View File
@@ -8,8 +8,9 @@ not fresh measurements on upstream main. No private document is needed for the b
- `crates/ui-egui/src/forms_ui.rs` queues `Edit::SetFieldValue` for a text commit or checkbox
toggle. `crates/automation/src/forms.rs::form_fill` uses the same edit API.
- `crates/engine/src/lib.rs::Session::apply` runs edits on a copy, preserving rollback and undo.
`run_edit` calls `pdfcraft_forms::set_value_with` with an engine `JsRunner`.
- `crates/engine/src/session.rs::Session::apply` runs edits on a copy, preserving rollback and
undo. `run_edit` (`crates/engine/src/edit.rs`) calls `pdfcraft_forms::set_value_with` with an
engine `JsRunner`.
- `crates/forms/src/lib.rs` validates and writes the edited value, then runs one calculation
pass. `/CO` precedes calculated fields omitted from `/CO`. Each script sees prior writes.
There is no dependency graph or dependency-based filtering: dynamic references and script
@@ -265,6 +266,8 @@ checks, not hosted CI or a new performance speedup claim.
## Revision history
- 2026-10-25: point the architecture notes at `session.rs` and `edit.rs`, where `Session::apply`
and `run_edit` moved when the engine's `lib.rs` was split.
- 2026-10-19: refresh PR #3318 onto upstream `e397efa8`, retain upstream roadmap additions
and all performance changes, and repeat focused, scoped, benchmark and headless validation.
+3 -1
View File
@@ -1,5 +1,7 @@
# PdfCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 9a9813bf and migrate viewer-only CJK configuration to the split engine modules; estimates unchanged)
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: document installed-font helpers and regression contracts; behavior and estimates unchanged)
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: print-preview font policy matches output, with isolated caches and GBK switching regressions; estimates unchanged)
@@ -177,7 +179,7 @@ total matches the parity estimate.
|---|---|---|---|---|---|
| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Done since: #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Missing: ≥ 250 tests, own image codecs |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). The desktop viewer can use installed CJK fonts, including Windows GBK Song/Hei/Kai/FangSong and subset-prefixed Founder names; Combine thumbnails and accessibility figure previews use the same display-only fallback, without changing export or print font policy (#234, continuation of #1567, review #4172). Print previews exclude installed faces and isolate their font cache; GBK regressions cover view/print switching for inline and pooled whole-page/tile rendering. Installed-font helpers and their regression contracts have function documentation. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). The desktop viewer can use installed CJK fonts, including Windows GBK Song/Hei/Kai/FangSong and subset-prefixed Founder names; Combine thumbnails and accessibility figure previews use the same display-only fallback, without changing export or print font policy (#234, continuation of #1567, review #4172). Print previews exclude installed faces and isolate their font cache; GBK regressions cover view/print switching for inline and pooled whole-page/tile rendering. Installed-font helpers and their regression contracts have function documentation. Integration with main 9a9813bf retains viewer fallback across the split open, layer and session modules. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. |