Merge upstream/main into main preserving custom features

- Resolve all 13 merge conflicts between upstream/main and main
- Preserve full image editing suite: OBB, crop, proportional resize, arrange, and context menu actions
- Preserve text editing formatting tools: bullet/numbered lists, subscript/superscript, and line spacing
- Cleanly integrate upstream's canvas modularization, font updates, and styling changes
- Refactor arrange_image and run_edit_page_image helpers to satisfy clippy cognitive complexity limits
- All engine, edit, and UI integration test suites passing cleanly
This commit is contained in:
Combiler01
2026-10-31 15:50:31 +05:30
565 changed files with 59061 additions and 14603 deletions
+3
View File
@@ -378,6 +378,8 @@ pub const COMMANDS: &[CommandSpec] = &[
c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"),
c("bookmark.add", "New bookmark", PAGES, Some(Shortcut::cmd("B")), Assembly, "bookmark-plus"),
c("bookmark.from_structure", "New bookmarks from structure", PAGES, None, Assembly, "list"),
// Bookmarks from the lines set in chosen text styles, for headings that aren't tagged (#1693).
c("bookmark.from_text_styles", "New bookmarks from text styles…", PAGES, None, Assembly, "list"),
c("page.rotate", "Rotate pages clockwise", PAGES, None, Assembly, "rotate-cw"),
c("page.rotate_ccw", "Rotate pages counterclockwise", PAGES, None, Assembly, "rotate-ccw"),
c("page.delete", "Delete pages", PAGES, None, Assembly, "trash-2"),
@@ -392,6 +394,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"),
c("page.split", "Split document…", PAGES, None, Assembly, "scissors"),
c("page.number", "Number pages…", PAGES, None, Assembly, "hash"),
c("page.properties", "Page properties…", PAGES, None, Modification, "info"),
c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"),
c("help.discord", "Join the ArtCraft Discord", HELP, None, Nothing, "messages-square"),
c("help.app_page", "PdfCraft web page", HELP, None, Nothing, "globe"),
+8 -3
View File
@@ -141,6 +141,7 @@ pub enum OfficeFormat {
Pptx,
Html,
Rtf,
Markdown,
}
impl OfficeFormat {
@@ -150,6 +151,7 @@ impl OfficeFormat {
OfficeFormat::Pptx => "pptx",
OfficeFormat::Html => "html",
OfficeFormat::Rtf => "rtf",
OfficeFormat::Markdown => "md",
}
}
@@ -159,12 +161,13 @@ impl OfficeFormat {
"pptx" => Some(OfficeFormat::Pptx),
"html" | "htm" => Some(OfficeFormat::Html),
"rtf" => Some(OfficeFormat::Rtf),
"md" | "markdown" => Some(OfficeFormat::Markdown),
_ => None,
}
}
}
/// A Word, PowerPoint, HTML or RTF export ([`crate::Document::export_office_checked`]).
/// A Word, PowerPoint, HTML, RTF or Markdown export ([`crate::Document::export_office_checked`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct OfficeExport {
/// The exported file.
@@ -275,14 +278,16 @@ impl crate::Document {
/// [`Self::export_office`], and the pages whose text it had to leave out
/// ([`OfficeExport::truncated_pages`]).
pub fn export_office_checked(&self, format: OfficeFormat) -> OfficeExport {
// RTF leaves pictures out.
let (pages, truncated_pages) = self.export_pages_checked(format != OfficeFormat::Rtf, MAX_EXPORT_IMAGE_BYTES);
// RTF and Markdown leave pictures out.
let (pages, truncated_pages) =
self.export_pages_checked(!matches!(format, OfficeFormat::Rtf | OfficeFormat::Markdown), MAX_EXPORT_IMAGE_BYTES);
let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string());
let bytes = match format {
OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title),
OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title),
OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(),
OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(),
OfficeFormat::Markdown => pdfcraft_export::markdown(&pages).into_bytes(),
};
OfficeExport { bytes, truncated_pages }
}
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, ArrangeOrder, EditError, Hidden, SignatureInfo, TrustStore, optimize};
/// A change to an existing page image.
#[derive(Clone, Debug, PartialEq)]
+82 -3
View File
@@ -59,9 +59,52 @@ pub struct Document {
/// 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>,
/// What the last successful [`Session::apply`](crate::Session::apply) wants said (see
/// [`EditNotice`]): text its fonts can't draw, pages whose own backdrop stays off. The
/// interface shows it; tools return it.
pub edit_notices: Vec<EditNotice>,
}
/// Something the last successful edit did that the user should hear about, never left silent
/// (AGENTS.md §4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EditNotice {
/// Text stored as typed but drawn with `?`.
Undrawn(TextNotice),
/// Remove background couldn't turn back on the white backdrop these pages (0-based,
/// ascending) paint themselves: their content changed after the background replaced it, so
/// turning it on could paint over something (`pdfcraft_edit::backdrops_left_off`). They look
/// the same on white paper.
BackdropsLeftOff { pages: Vec<usize> },
}
impl std::fmt::Display for EditNotice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EditNotice::Undrawn(n) => n.fmt(f),
EditNotice::BackdropsLeftOff { pages } => {
let (which, own) = if pages.len() == 1 { ("page", "its") } else { ("pages", "their") };
write!(
f,
"{which} {} changed after the background was added, so {own} own white backdrop was not turned back on (it looks the same on white paper)",
page_runs(pages)
)
}
}
}
}
/// 0-based pages as 1-based runs: `[0, 1, 2, 5]` is "1-3, 6".
pub(crate) fn page_runs(pages: &[usize]) -> String {
let mut runs: Vec<(usize, usize)> = Vec::new();
for &p in pages {
match runs.last_mut() {
Some((_, end)) if end.checked_add(1) == Some(p) => *end = p,
_ => runs.push((p, p)),
}
}
let one = |p: usize| p.saturating_add(1);
runs.iter().map(|&(a, b)| if a == b { one(a).to_string() } else { format!("{}-{}", one(a), one(b)) }).collect::<Vec<_>>().join(", ")
}
/// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn
@@ -156,6 +199,20 @@ impl Document {
self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default()
}
/// The backdrop `page` (0-based) paints itself, which hides a background (Add Background).
pub fn page_backdrop(&self, page: usize) -> Option<pdfcraft_edit::Backdrop> {
self.editor.as_ref().and_then(|e| pdfcraft_edit::page_backdrop(&e.cos, page).ok().flatten())
}
/// [`Self::page_backdrop`] for each of `pages`, in order, walking the page tree once: the
/// way to check many pages. A page out of range has none.
pub fn page_backdrops(&self, pages: &[usize]) -> Vec<Option<pdfcraft_edit::Backdrop>> {
match &self.editor {
Some(e) => pdfcraft_edit::page_backdrops(&e.cos, pages),
None => vec![None; pages.len()],
}
}
/// 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()
@@ -237,6 +294,16 @@ impl Document {
self.generation
}
/// The styles the document's text is set in, the largest first, for bookmarks from text
/// styles (#1693).
/// Empty when the document doesn't allow copying: the samples are its text.
pub fn text_styles(&self) -> Vec<crate::text_style_bookmarks::TextStyleUse> {
if !self.allows_copying() {
return Vec::new();
}
self.editor.as_ref().map(|e| crate::text_style_bookmarks::styles(&e.cos)).unwrap_or_default()
}
/// 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()
@@ -248,6 +315,11 @@ impl Document {
self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default()
}
/// Page properties ▸ Tab order: each page's tab order, in page order.
pub fn tab_orders(&self) -> Vec<TabOrder> {
self.editor.as_ref().map(|e| pdfcraft_forms::tab_orders(&e.cos)).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()
@@ -305,6 +377,13 @@ impl Document {
self.config.hide_comments
}
/// Whether `Edit::DeleteAnnotation` would delete the annotation at `(page, index)`: the
/// engine's own check (it, and every reply that would go with it, unlocked), for a Delete
/// command to ask before it is offered.
pub fn annotation_deletable(&self, page: usize, index: usize) -> bool {
self.editor.as_ref().is_some_and(|e| pdfcraft_annot::deletable(&e.cos, page, index).is_ok())
}
pub fn can_undo(&self) -> Option<&str> {
self.undo_label().map(Label::as_str)
}
+13 -1
View File
@@ -122,6 +122,11 @@ pub enum Edit {
/// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first
/// "Untitled" bookmark (New Bookmarks from Structure).
BookmarksFromStructure,
/// Bookmarks for the lines set in `levels`' styles (the first is level 1, the next nests
/// under it, …), under a new first "Untitled" bookmark (#1693; see [`crate::text_style_bookmarks`]).
BookmarksFromTextStyles {
levels: Vec<crate::text_style_bookmarks::TextStyle>,
},
/// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels.
NumberPages {
from: usize,
@@ -287,6 +292,12 @@ pub enum Edit {
DeleteField {
name: String,
},
/// Delete one widget of a field (`widget`, 0-based in the field's widgets): one radio button
/// of a group, or one copy of a duplicated field. The last widget takes the field with it.
DeleteWidget {
name: String,
widget: usize,
},
/// Page properties ▸ Tab order, for pages (0-based).
SetTabOrder {
pages: Vec<usize>,
@@ -517,7 +528,8 @@ pub enum Edit {
links: Vec<(usize, Vec<[f64; 4]>, String)>,
style: LinkStyle,
},
/// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text).
/// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text), or the
/// comments of another PDF onto the pages with the same numbers.
ImportData {
name: String,
bytes: Arc<Vec<u8>>,
+4 -2
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Edit, FillMark, ImageEdit, Label, LineEnding, MarkKind, Markup, PageBox, ReviewState, Shape, field_table};
/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not
/// here: its wording is only the hole, because a batch carries its name as text (see
@@ -39,6 +39,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Colour",
"Style",
"New bookmarks from structure",
"New bookmarks from text styles",
"Number pages",
"Measure distance",
"Measure perimeter",
@@ -193,6 +194,7 @@ impl Edit {
Edit::SetBookmarkColor { .. } => Label::of("Colour"),
Edit::SetBookmarkStyle { .. } => Label::of("Style"),
Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"),
Edit::BookmarksFromTextStyles { .. } => Label::of("New bookmarks from text styles"),
Edit::NumberPages { .. } => Label::of("Number pages"),
// What was measured is part of the wording too, for the same reason.
Edit::AddMeasurement(m) => Label::of(match m.kind {
@@ -231,7 +233,7 @@ impl Edit {
Edit::ResetForm { .. } => Label::of("Clear form"),
Edit::AddField { .. } => Label::of("Add field"),
Edit::SetFieldProps { .. } => Label::of("Change field properties"),
Edit::DeleteField { .. } => Label::of("Delete field"),
Edit::DeleteField { .. } | Edit::DeleteWidget { .. } => Label::of("Delete field"),
Edit::DuplicateField { .. } => Label::of("Duplicate field"),
Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"),
Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"),
+283 -201
View File
@@ -1,4 +1,9 @@
use super::*;
use super::{
AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture,
image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
};
use crate::SourceProblem;
use crate::sources::{comment_source, import_pdf_comments};
/// Whether the opening password allows an edit (§7.6.4.2, Table 22).
pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> {
@@ -29,6 +34,17 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
Err(EditError::NotPermitted("page changes"))
}
}
// Creates bookmarks (assembly), and their titles are the document's text, copied out of it
// (Table 22, bit 5), as an export of it would be.
Edit::BookmarksFromTextStyles { .. } => {
if !p.assemble() {
Err(EditError::NotPermitted("page changes"))
} else if !p.copy() {
Err(EditError::NotPermitted("copying text and images"))
} else {
Ok(())
}
}
Edit::AddMeasurement(_)
| Edit::AddAnnotation(_)
| Edit::AddCustomStamp { .. }
@@ -103,6 +119,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
| Edit::XfaToStatic
| Edit::SetFieldProps { .. }
| Edit::DeleteField { .. }
| Edit::DeleteWidget { .. }
| Edit::DuplicateField { .. }
| Edit::SetTabOrder { .. }
| Edit::MoveInTabOrder { .. }
@@ -160,6 +177,8 @@ pub(crate) struct EditCtx {
pub(crate) today: (i64, u32, u32),
pub(crate) seed: u64,
pub(crate) count: u64,
/// Platform CSPRNG (`getrandom::fill`). Tests replace it so Protect can fail closed.
pub(crate) rng: fn(&mut [u8]) -> Result<(), getrandom::Error>,
/// Fields the edit renamed to keep imported names separate (old → new), for the repair
/// log and the undo label.
pub(crate) renamed_fields: Vec<(String, String)>,
@@ -192,24 +211,21 @@ impl EditCtx {
today: (1970, 1, 1),
seed,
count: 0,
rng: getrandom::fill,
renamed_fields: Vec::new(),
redaction_overlays: Vec::new(),
}
}
/// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the
/// operating system, so no extra dependency is needed.
fn entropy(&mut self) -> [u8; 32] {
use std::hash::{BuildHasher, Hasher};
/// 32 bytes of entropy for new encryption keys, salts and generated owner passwords from the
/// platform CSPRNG. Fails closed when secure randomness is unavailable.
fn entropy(&mut self) -> Result<[u8; 32], EditError> {
let mut out = [0u8; 32];
for (i, chunk) in out.chunks_mut(8).enumerate() {
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
h.write_u64(self.seed ^ i as u64);
h.write_u64(self.count);
self.count += 1;
chunk.copy_from_slice(&h.finish().to_le_bytes());
(self.rng)(&mut out).map_err(|e| EditError::Protection(format!("no secure randomness available: {e}")))?;
if out == [0u8; 32] {
return Err(EditError::Protection("no secure randomness available".into()));
}
out
Ok(out)
}
/// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream.
@@ -234,6 +250,194 @@ impl EditCtx {
}
}
fn run_edit_page_image(
doc: &mut pdfcraft_cos::Document,
page: usize,
index: usize,
change: &ImageEdit,
) -> Result<(), EditError> {
match change {
ImageEdit::Move(to) if !to.iter().all(|v| v.is_finite()) => {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid position".into())));
}
ImageEdit::Transform(t) if !t.iter().all(|v| v.is_finite()) => {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("the image transform must be finite".into())));
}
ImageEdit::Crop(keep) if !keep.iter().all(|v| v.is_finite()) => {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid crop".into())));
}
_ => {}
}
let img = pdfcraft_edit::page_images(doc, page)?
.into_iter()
.nth(index)
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?;
let c = match change {
ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)),
ImageEdit::Transform(t) => pdfcraft_edit::ImageChange::Transform(*t),
ImageEdit::Rotate(q) => {
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false))
}
ImageEdit::RotateBy(degrees) => {
let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees)
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?;
pdfcraft_edit::ImageChange::Transform(t)
}
ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(
img.visible_rect.unwrap_or(img.rect),
0,
*horizontal,
!*horizontal,
)),
ImageEdit::Replace { name, bytes } => {
if img.is_form {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid(
"grouped Form artwork cannot be replaced with a bitmap".into(),
)));
}
pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0)
}
ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep),
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
ImageEdit::Arrange(order) => pdfcraft_edit::ImageChange::Arrange(*order),
ImageEdit::RemoveCrop => pdfcraft_edit::ImageChange::RemoveCrop,
};
let page_model = pdfcraft_model::pages(doc)
.into_iter()
.nth(page)
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} not found", page + 1))))?;
let target_stream_obj = match page_model.dict.get(b"Contents") {
None => None,
Some(c) => match &*doc.resolve(c) {
pdfcraft_cos::Object::Array(a) => a.get(img.stream).and_then(|o| o.as_ref()),
_ if img.stream == 0 => c.as_ref(),
_ => None,
},
};
let added_items: Vec<(usize, pdfcraft_edit::Added)> =
pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == page).enumerate().collect();
let matched_added = if let Some(st_ref) = target_stream_obj {
added_items.iter().find(|(_, a)| a.obj == st_ref)
} else if let Some(target_ref) = img.object {
added_items.iter().find(|(_, a)| matches!(&a.content, AddedContent::Image(ai) if ai.image == target_ref))
} else {
None
};
let mut ai_to_update = None;
if let Some((added_idx, added_item)) = matched_added
&& let AddedContent::Image(ai) = added_item.content.clone()
{
let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6)
|| img.visible_rect.is_some_and(|v| {
(v[0] - img.rect[0]).abs() > 1e-4
|| (v[1] - img.rect[1]).abs() > 1e-4
|| (v[2] - img.rect[2]).abs() > 1e-4
|| (v[3] - img.rect[3]).abs() > 1e-4
});
let rotated_transform = matches!(change, ImageEdit::Transform(t) if t[1].abs() >= 1e-6 || t[2].abs() >= 1e-6);
let should_promote = has_active_crop
|| rotated_transform
|| matches!(
change,
ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete
);
if should_promote {
doc.update_dict(added_item.obj, |d| {
d.remove(b"PCAdded");
d.remove(b"PCMark");
})
.map_err(|e| EditError::Edit(pdfcraft_edit::EditError::Invalid(e.to_string())))?;
} else {
ai_to_update = Some((*added_idx, ai));
}
}
if let Some((added_idx, mut ai)) = ai_to_update {
let view = page_model.view_matrix(doc);
let det = view[0] * view[3] - view[1] * view[2];
if det.abs() < 1e-12 || !det.is_finite() {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid page transform".into())));
}
let (ia, ib, ic, id) = (view[3] / det, -view[1] / det, -view[2] / det, view[0] / det);
let to_display = [ia, ib, ic, id, -(view[4] * ia + view[5] * ic), -(view[4] * ib + view[5] * id)];
let apply = |m: [f64; 6], x: f64, y: f64| (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]);
let bbox = |m: [f64; 6], r: [f64; 4]| {
let pts = [apply(m, r[0], r[1]), apply(m, r[2], r[1]), apply(m, r[0], r[3]), apply(m, r[2], r[3])];
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
for (x, y) in pts {
b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)];
}
b
};
match change {
ImageEdit::Move(to) => {
if !to.iter().all(|v| v.is_finite()) {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid position".into())));
}
ai.rect = bbox(to_display, *to);
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Transform(t) => {
let mul = |m1: [f64; 6], m2: [f64; 6]| {
[
m1[0] * m2[0] + m1[1] * m2[2],
m1[0] * m2[1] + m1[1] * m2[3],
m1[2] * m2[0] + m1[3] * m2[2],
m1[2] * m2[1] + m1[3] * m2[3],
m1[4] * m2[0] + m1[5] * m2[2] + m2[4],
m1[4] * m2[1] + m1[5] * m2[3] + m2[5],
]
};
let t_display = mul(mul(view, *t), to_display);
if t_display[0] < 0.0 {
ai.flip_h = !ai.flip_h;
}
if t_display[3] < 0.0 {
ai.flip_v = !ai.flip_v;
}
ai.rect = bbox(t_display, ai.rect);
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Rotate(q) => {
let k = ((4 - q.rem_euclid(4)) % 4) as u8;
ai.rotation = (ai.rotation + k) % 4;
let r = ai.rect;
let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
if k % 2 == 1 {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
ai.rect = [cx - h / 2.0, cy - w / 2.0, cx + h / 2.0, cy + w / 2.0];
}
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Flip { horizontal: true } => {
ai.flip_h = !ai.flip_h;
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Flip { horizontal: false } => {
ai.flip_v = !ai.flip_v;
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Replace { .. } => {
let pdfcraft_edit::ImageChange::Replace(new_image) = c else {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected replace".into())));
};
ai.image = new_image;
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete => {
pdfcraft_edit::change_image(doc, page, index, &c)?;
}
}
} else {
pdfcraft_edit::change_image(doc, page, index, &c)?;
}
Ok(())
}
/// Perform an edit on a working copy (the caller discards it on error).
pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> {
match edit {
@@ -277,10 +481,30 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::BookmarksFromStructure => {
let entries: Vec<_> = pdfcraft_a11y::headings(doc)
.into_iter()
.map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj })
.map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj, top: None })
.collect();
pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?;
}
Edit::BookmarksFromTextStyles { levels } => {
if levels.is_empty() {
return Err(EditError::Invalid("choose a text style for at least one bookmark level".into()));
}
// Each line goes to the first level whose style it is in: a style given again, or one
// within the size tolerance of an earlier level's, would leave its level empty.
for (i, a) in levels.iter().enumerate() {
if let Some(j) = levels.iter().skip(i.saturating_add(1)).position(|b| a.matches(b)) {
let (first, second) = (i.saturating_add(1), i.saturating_add(j).saturating_add(2));
return Err(EditError::Invalid(format!(
"bookmark levels {first} and {second} have the same text style: choose a different one for each level"
)));
}
}
let entries = crate::text_style_bookmarks::headings(doc, levels);
if entries.is_empty() {
return Err(EditError::Invalid("no text is set in the chosen styles".into()));
}
pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?;
}
Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?,
Edit::AddMeasurement(m) => {
measure::add(doc, m, &cx.meta())?;
@@ -328,7 +552,19 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
// One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by
// the strikeout's `/NM` (#2666).
let (strike_meta, caret_meta) = (cx.meta(), cx.meta());
pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?;
pdfcraft_annot::add_text_replacement(
doc,
pdfcraft_annot::TextReplacement {
page: *page,
quads,
replacement: text,
author,
strike,
caret,
strike_meta: &strike_meta,
caret_meta: &caret_meta,
},
)?;
}
Edit::MarkAnnotation { page, index, marked, author } => {
pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?;
@@ -338,7 +574,13 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?,
Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?,
Edit::StyleAnnotation { page, index, color, opacity, width, endings } => {
pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?;
pdfcraft_annot::set_style(
doc,
*page,
*index,
pdfcraft_annot::StyleChange { color: *color, opacity: *opacity, width: *width, endings: endings.clone() },
&cx.meta(),
)?;
}
Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?,
Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?,
@@ -425,6 +667,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
}
}
Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?,
Edit::DeleteWidget { name, widget } => pdfcraft_forms::delete_widget(doc, name, *widget)?,
Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?,
Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?,
// As after a script edit: the values the new order gives show at once.
@@ -470,187 +713,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::AddOcrText { page, words } => {
pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?;
}
Edit::EditPageImage { page, index, change } => {
match change {
ImageEdit::Move(to) if !to.iter().all(|v| v.is_finite()) => {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid position".into())));
}
ImageEdit::Transform(t) if !t.iter().all(|v| v.is_finite()) => {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("the image transform must be finite".into())));
}
ImageEdit::Crop(keep) if !keep.iter().all(|v| v.is_finite()) => {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid crop".into())));
}
_ => {}
}
let img = pdfcraft_edit::page_images(doc, *page)?
.into_iter()
.nth(*index)
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?;
let c = match change {
ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)),
ImageEdit::Transform(t) => pdfcraft_edit::ImageChange::Transform(*t),
ImageEdit::Rotate(q) => {
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false))
}
ImageEdit::RotateBy(degrees) => {
let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees)
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?;
pdfcraft_edit::ImageChange::Transform(t)
}
ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(
img.visible_rect.unwrap_or(img.rect),
0,
*horizontal,
!*horizontal,
)),
ImageEdit::Replace { name, bytes } => {
if img.is_form {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid(
"grouped Form artwork cannot be replaced with a bitmap".into(),
)));
}
pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0)
}
ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep),
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
ImageEdit::Arrange(order) => pdfcraft_edit::ImageChange::Arrange(*order),
ImageEdit::RemoveCrop => pdfcraft_edit::ImageChange::RemoveCrop,
};
let page_model = pdfcraft_model::pages(doc)
.into_iter()
.nth(*page)
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} not found", page + 1))))?;
let target_stream_obj = match page_model.dict.get(b"Contents") {
None => None,
Some(c) => match &*doc.resolve(c) {
pdfcraft_cos::Object::Array(a) => a.get(img.stream).and_then(|o| o.as_ref()),
_ if img.stream == 0 => c.as_ref(),
_ => None,
},
};
let added_items: Vec<(usize, pdfcraft_edit::Added)> =
pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).enumerate().collect();
let matched_added = if let Some(st_ref) = target_stream_obj {
added_items.iter().find(|(_, a)| a.obj == st_ref)
} else if let Some(target_ref) = img.object {
added_items.iter().find(|(_, a)| matches!(&a.content, AddedContent::Image(ai) if ai.image == target_ref))
} else {
None
};
let mut ai_to_update = None;
if let Some((added_idx, added_item)) = matched_added
&& let AddedContent::Image(ai) = added_item.content.clone()
{
let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6)
|| img.visible_rect.is_some_and(|v| {
(v[0] - img.rect[0]).abs() > 1e-4
|| (v[1] - img.rect[1]).abs() > 1e-4
|| (v[2] - img.rect[2]).abs() > 1e-4
|| (v[3] - img.rect[3]).abs() > 1e-4
});
let rotated_transform = matches!(change, ImageEdit::Transform(t) if t[1].abs() >= 1e-6 || t[2].abs() >= 1e-6);
let should_promote = has_active_crop
|| rotated_transform
|| matches!(
change,
ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete
);
if should_promote {
doc.update_dict(added_item.obj, |d| {
d.remove(b"PCAdded");
d.remove(b"PCMark");
})
.map_err(|e| EditError::Edit(pdfcraft_edit::EditError::Invalid(e.to_string())))?;
} else {
ai_to_update = Some((*added_idx, ai));
}
}
if let Some((added_idx, mut ai)) = ai_to_update {
let view = page_model.view_matrix(doc);
let det = view[0] * view[3] - view[1] * view[2];
if det.abs() < 1e-12 || !det.is_finite() {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid page transform".into())));
}
let (ia, ib, ic, id) = (view[3] / det, -view[1] / det, -view[2] / det, view[0] / det);
let to_display = [ia, ib, ic, id, -(view[4] * ia + view[5] * ic), -(view[4] * ib + view[5] * id)];
let apply = |m: [f64; 6], x: f64, y: f64| (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]);
let bbox = |m: [f64; 6], r: [f64; 4]| {
let pts = [apply(m, r[0], r[1]), apply(m, r[2], r[1]), apply(m, r[0], r[3]), apply(m, r[2], r[3])];
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
for (x, y) in pts {
b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)];
}
b
};
match change {
ImageEdit::Move(to) => {
if !to.iter().all(|v| v.is_finite()) {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid position".into())));
}
ai.rect = bbox(to_display, *to);
pdfcraft_edit::update_content(doc, *page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Transform(t) => {
let mul = |m1: [f64; 6], m2: [f64; 6]| {
[
m1[0] * m2[0] + m1[1] * m2[2],
m1[0] * m2[1] + m1[1] * m2[3],
m1[2] * m2[0] + m1[3] * m2[2],
m1[2] * m2[1] + m1[3] * m2[3],
m1[4] * m2[0] + m1[5] * m2[2] + m2[4],
m1[4] * m2[1] + m1[5] * m2[3] + m2[5],
]
};
let t_display = mul(mul(view, *t), to_display);
if t_display[0] < 0.0 {
ai.flip_h = !ai.flip_h;
}
if t_display[3] < 0.0 {
ai.flip_v = !ai.flip_v;
}
ai.rect = bbox(t_display, ai.rect);
pdfcraft_edit::update_content(doc, *page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Rotate(q) => {
let k = ((4 - q.rem_euclid(4)) % 4) as u8;
ai.rotation = (ai.rotation + k) % 4;
let r = ai.rect;
let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
if k % 2 == 1 {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
ai.rect = [cx - h / 2.0, cy - w / 2.0, cx + h / 2.0, cy + w / 2.0];
}
pdfcraft_edit::update_content(doc, *page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Flip { horizontal: true } => {
ai.flip_h = !ai.flip_h;
pdfcraft_edit::update_content(doc, *page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Flip { horizontal: false } => {
ai.flip_v = !ai.flip_v;
pdfcraft_edit::update_content(doc, *page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Replace { .. } => {
let pdfcraft_edit::ImageChange::Replace(new_image) = c else {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected replace".into())));
};
ai.image = new_image;
pdfcraft_edit::update_content(doc, *page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete => {
pdfcraft_edit::change_image(doc, *page, *index, &c)?;
}
}
} else {
pdfcraft_edit::change_image(doc, *page, *index, &c)?;
}
}
Edit::EditPageImage { page, index, change } => run_edit_page_image(doc, *page, *index, change)?,
Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?,
Edit::EditTextBlock { page, block, text, style } => {
pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?;
@@ -759,7 +822,11 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into())));
}
}
Edit::ImportData { bytes, .. } => {
Edit::ImportData { name, bytes } if bytes.starts_with(b"%PDF-") => {
let src = comment_source(bytes).map_err(|p| EditError::Source(format!("{name}: {p}")))?;
import_pdf_comments(doc, &src)?;
}
Edit::ImportData { name, bytes } => {
// An XFA form takes XFA data files (XDP, datasets, or a data document) as its data;
// XFDF, FDF, Acrobat's XML and tables go the usual way.
let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten();
@@ -773,9 +840,21 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes));
xfa_after_data_change(doc, cx, true)?;
}
Some(Err(why)) => return Err(EditError::Invalid(why)),
Some(Err(why)) if !bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => return Err(EditError::Invalid(why)),
_ => {
pdfcraft_xfdf::import(doc, bytes)?;
// A PDF with bytes before its header, within the 1,024 the reader allows, is read
// as a PDF when it is not a data file. A file the PDF reader cannot open keeps
// the data importer's error.
let mut data = doc.clone();
match pdfcraft_xfdf::import(&mut data, bytes) {
Ok(_) => *doc = data,
Err(e) if bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => match comment_source(bytes) {
Ok(src) => import_pdf_comments(doc, &src)?,
Err(SourceProblem::Unreadable(_)) => return Err(e.into()),
Err(p) => return Err(EditError::Source(format!("{name}: {p}"))),
},
Err(e) => return Err(e.into()),
}
}
}
}
@@ -813,10 +892,13 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
}
Edit::Protect(p) => {
p.validate()?;
let seed = cx.entropy();
let seed = cx.entropy()?;
// Without a permissions password nothing is restricted, so the owner password is a
// random one nobody needs.
let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect();
let random_owner = match p.permissions_password {
Some(_) => String::new(),
None => cx.entropy()?.iter().map(|b| format!("{b:02x}")).collect(),
};
let params = pdfcraft_cos::NewEncryption {
algorithm: p.algorithm,
user_password: p.open_password.as_deref().unwrap_or(""),
+185 -5
View File
@@ -4,13 +4,52 @@
//! text, from a
//! document's working file (so unsaved edits and hidden layers are respected, as on screen).
use std::collections::BTreeMap;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Condvar, Mutex, PoisonError, mpsc};
use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind};
use crate::Document;
/// At most this many pages are drawn at once by [`Exporter::images`].
const MAX_IMAGE_WORKERS: usize = 8;
/// Bytes of rasters [`Exporter::images`] may hold at once: 1 GiB, about 16 Letter pages at 300 dpi.
/// Each worker holds one page (the raster, its RGB copy and the encoded file), so large pages run
/// on fewer workers; a page over the cap still exports, on one worker, as it did before.
const IMAGE_MEMORY: f64 = 1024.0 * 1024.0 * 1024.0;
/// Page `page` drawn at `dpi` and encoded as `format`, saying it was drawn at `used` dpi.
fn draw_image(renderer: &mut PageRenderer, page: usize, dpi: f64, format: ImageFormat, used: f64) -> Result<Vec<u8>, String> {
let r = renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() });
if let Some(e) = r.error {
return Err(format!("page {}: {e}", page + 1));
}
encode_image(r.width, r.height, &r.rgba, format, used)
}
/// Tells the workers of [`Exporter::images`] to stop when dropped.
struct StopWorkers<'a> {
stop: &'a AtomicBool,
handed_over: &'a (Mutex<usize>, Condvar),
}
impl Drop for StopWorkers<'_> {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
// Taken under the lock, so a worker between its check and its wait still sees it.
drop(self.handed_over.0.lock().unwrap_or_else(PoisonError::into_inner));
self.handed_over.1.notify_all();
}
}
/// Renders and extracts pages of one document state.
pub struct Exporter {
renderer: PageRenderer,
/// What the renderer draws, for the extra renderers [`Exporter::images`] starts.
bytes: std::sync::Arc<Vec<u8>>,
config: pdfcraft_render::RenderConfig,
pages: usize,
/// Each page's displayed size in points, for [`Exporter::dpi_used`].
sizes: Vec<(f32, f32)>,
@@ -65,7 +104,13 @@ impl Exporter {
/// resolution they allow, as the interactive Export does; [`Exporter::dpi_used`] says at what.
/// A source whose `config.reject_oversize` is set refuses such pages instead.
pub fn from_source(src: ExportSource) -> Self {
Self { renderer: PageRenderer::new(src.bytes, src.config), pages: src.pages, sizes: src.sizes }
Self {
renderer: PageRenderer::new(src.bytes.clone(), src.config.clone()),
bytes: src.bytes,
config: src.config,
pages: src.pages,
sizes: src.sizes,
}
}
/// The resolution page `page` is actually exported at when asked for `dpi`: lower than asked
@@ -96,11 +141,146 @@ impl Exporter {
/// Page `page` as an image file of `format`, which says the resolution it was drawn at.
pub fn image(&mut self, page: usize, dpi: f64, format: ImageFormat) -> Result<Vec<u8>, String> {
self.check(page)?;
let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() });
if let Some(e) = r.error {
return Err(format!("page {}: {e}", page + 1));
let used = self.dpi_used(page, dpi);
draw_image(&mut self.renderer, page, dpi, format, used)
}
/// Pages `pages` as image files of `format`, handed to `sink` in order with their page
/// number: the same files as [`Exporter::image`] for each page in turn. Encoding a page costs
/// several times what drawing it does, so several pages are drawn and encoded at once on
/// worker threads, each with its own renderer, while `sink` runs on the calling thread.
/// Workers draw at most one page each ahead of `sink`, and fewer run when the pages are
/// large, so memory stays bounded. Stops at the first page that fails or the first error `sink`
/// returns, and returns it; every page before it has been handed to `sink`.
pub fn images(
&mut self,
pages: &[usize],
dpi: f64,
format: ImageFormat,
sink: impl FnMut(usize, Vec<u8>) -> Result<(), String>,
) -> Result<(), String> {
let workers = self.image_workers(pages, dpi);
self.images_on(workers, pages, dpi, format, sink)
}
/// [`Exporter::images`] with `workers` pages drawn at once (one: in turn, on this thread).
pub(crate) fn images_on(
&mut self,
workers: usize,
pages: &[usize],
dpi: f64,
format: ImageFormat,
mut sink: impl FnMut(usize, Vec<u8>) -> Result<(), String>,
) -> Result<(), String> {
if workers <= 1 {
for &p in pages {
let img = self.image(p, dpi, format)?;
sink(p, img)?;
}
return Ok(());
}
encode_image(r.width, r.height, &r.rgba, format, self.dpi_used(page, dpi))
let used: Vec<f64> = pages.iter().map(|p| self.dpi_used(*p, dpi)).collect();
let count = self.pages;
let (bytes, config) = (&self.bytes, &self.config);
let next = AtomicUsize::new(0);
let stop = AtomicBool::new(false);
// How many pages `sink` has taken; a worker waits while its page is `workers` past it.
let handed_over = (Mutex::new(0usize), Condvar::new());
let (tx, rx) = mpsc::channel::<(usize, Result<Vec<u8>, String>)>();
let mut first = Some(&mut self.renderer);
std::thread::scope(|s| {
let handles: Vec<_> = (0..workers)
.map(|_| {
let tx = tx.clone();
let (first, used, next, stop, handed_over) = (first.take(), &used, &next, &stop, &handed_over);
s.spawn(move || {
let mut own = None;
let renderer = match first {
Some(r) => r,
None => own.insert(PageRenderer::new(bytes.clone(), config.clone())),
};
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let (Some(&page), Some(&used)) = (pages.get(i), used.get(i)) else { break };
let mut taken = handed_over.0.lock().unwrap_or_else(PoisonError::into_inner);
while i >= taken.saturating_add(workers) && !stop.load(Ordering::Relaxed) {
taken = handed_over.1.wait(taken).unwrap_or_else(PoisonError::into_inner);
}
drop(taken);
if stop.load(Ordering::Relaxed) {
break;
}
let img = if page < count {
catch_unwind(AssertUnwindSafe(|| draw_image(renderer, page, dpi, format, used)))
.unwrap_or_else(|_| Err(format!("page {}: the export crashed", page + 1)))
} else {
Err(format!("page {} does not exist", page + 1))
};
if tx.send((i, img)).is_err() {
break;
}
}
})
})
.collect();
drop(tx);
// Stops the workers however this ends, a panicking `sink` included: waiting ones
// would otherwise keep the scope from returning.
let stopper = StopWorkers { stop: &stop, handed_over: &handed_over };
let mut ready = BTreeMap::new();
let mut result = Ok(());
let mut taken = 0;
while let Some(&page) = pages.get(taken) {
let Some(img) = ready.remove(&taken) else {
match rx.recv() {
Ok((i, img)) => {
ready.insert(i, img);
}
Err(_) => {
result = Err(format!("page {}: the export stopped unexpectedly", page + 1));
break;
}
}
continue;
};
if let Err(e) = img.and_then(|img| sink(page, img)) {
result = Err(e);
break;
}
taken += 1;
*handed_over.0.lock().unwrap_or_else(PoisonError::into_inner) = taken;
handed_over.1.notify_all();
}
drop(stopper);
// Joined here, so a worker that panicked outside a page is not re-raised by the scope.
for h in handles {
let _ = h.join();
}
result
})
}
/// How many pages [`Exporter::images`] draws at once: one per core but one, up to
/// [`MAX_IMAGE_WORKERS`], as many as [`IMAGE_MEMORY`] holds of the largest page, and one
/// when there is nothing to share or no threads (wasm).
fn image_workers(&self, pages: &[usize], dpi: f64) -> usize {
if cfg!(target_arch = "wasm32") || pages.len() < 2 {
return 1;
}
// One core is left for the interface, which keeps drawing while an export runs.
let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get).saturating_sub(1).min(MAX_IMAGE_WORKERS);
// The raster (RGBA), the RGB copy the encoders take, and room for the encoded file.
let largest = pages
.iter()
.filter_map(|p| {
let (w, h) = *self.sizes.get(*p)?;
let k = self.dpi_used(*p, dpi) / 72.0;
Some((f64::from(w) * k).ceil() * (f64::from(h) * k).ceil() * 8.0)
})
.fold(0.0, f64::max);
// A float-to-integer cast saturates (and NaN is 0).
let by_memory = if largest > 0.0 { (IMAGE_MEMORY / largest) as usize } else { cores };
cores.min(by_memory).min(pages.len()).max(1)
}
/// The reading-order text of a page.
+6 -3
View File
@@ -7,14 +7,15 @@
//! off, scripts don't run and AF calls keep working natively.
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts};
pub use pdfcraft_js::{Limits, Outcome, Panel, Request};
pub use pdfcraft_js::{Limits, Message, MessageButtons, MessageIcon, Outcome, Panel, Request};
use crate::{DocId, Edit, EditError, Session};
/// What scripts produced for the viewer since it last looked.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct JsOutput {
pub alerts: Vec<String>,
/// The messages scripts showed, in order, with their titles, icons and buttons.
pub alerts: Vec<Message>,
pub console: Vec<String>,
pub requests: Vec<Request>,
/// Script errors (as Acrobat's console shows them).
@@ -195,6 +196,8 @@ fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> {
}
impl Scripts for JsRunner {
/// Run field `target`'s script for `event` over the form's fields, keeping what it showed and
/// asked for; a refusal carries its last message.
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult {
let states: Vec<_> = fields
.iter()
@@ -218,7 +221,7 @@ impl Scripts for JsRunner {
rc: o.rc,
value: o.value.clone(),
changes: changes(&states, &o.changed),
message: (!o.rc).then(|| o.alerts.last().cloned()).flatten(),
message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(),
}
}
}
+2 -2
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use pdfcraft_render::{Layer, LayerOp, RenderPool};
use crate::{Document, render_threads};
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
@@ -31,7 +31,7 @@ pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, 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(), doc.config.clone());
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
+17 -5
View File
@@ -54,6 +54,7 @@ mod stamp_preview;
mod test_hooks;
pub mod text_fonts;
mod text_preview;
pub mod text_style_bookmarks;
pub mod word_count;
pub mod xfa;
mod xfa_form;
@@ -65,8 +66,8 @@ pub use links::{
};
pub use signature_image::{ImageSignaturePreview, SignatureImage};
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
pub use stamp_preview::StampPreview;
pub use text_preview::TextEditPreview;
pub use stamp_preview::{CommentLayer, StampPreview};
pub use text_preview::{TextEditPreview, UnmarkedPages};
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges};
@@ -81,6 +82,7 @@ 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::paragraph_align;
pub use pdfcraft_edit::{
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
};
@@ -88,11 +90,11 @@ 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,
appearance::comb_auto_text_size as field_comb_auto_text_size, 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_edit::{Backdrop, BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable};
pub use pdfcraft_measure as measure;
pub use pdfcraft_xfa::Report as XfaLayout;
@@ -124,7 +126,9 @@ use bookmarks::outline_rows;
pub use bookmarks::{BookmarkPage, BookmarkRow};
use comments::comment_list;
pub use comments::{SummarySort, comment_kind, comment_summary, comment_type_name};
pub use document::{Document, TextNotice, display_bytes_for, render_password_for};
#[cfg(test)]
use document::page_runs;
pub use document::{Document, EditNotice, TextNotice, display_bytes_for, render_password_for};
use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices};
pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit};
pub use error::{EditError, SourceProblem};
@@ -156,5 +160,13 @@ use clock::{local_utc_offset, parse_ymd};
pub use doc_state::{DocId, ImageEdit, OptimizeReport, RecoverySnapshot, SplitPart, guard, merge_data_files};
use doc_state::{LOW_RESOLUTION_PRINT_DPI, render_threads, renderer_password, signatures_of, writable};
/// The configuration of the pool that draws the viewer: the document's own, plus installed CJK
/// fonts for fonts a PDF does not embed (`pdfcraft_render::RenderConfig::system_fonts`). Exports,
/// printing, OCR and automation use the document configuration itself, so nothing drawn with an
/// installed font is saved or printed (AGENTS.md §1.2, §1.4).
fn display_config(config: &pdfcraft_render::RenderConfig) -> pdfcraft_render::RenderConfig {
config.clone().on_screen()
}
#[cfg(test)]
mod tests;
+18 -16
View File
@@ -7,8 +7,8 @@ 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, guard, info_fields, js, note_warnings,
recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets,
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.
@@ -25,6 +25,17 @@ pub struct PreparedDocument {
document: Document,
}
struct DocumentOpenData<'a> {
name: String,
path: Option<String>,
bytes: Arc<Vec<u8>>,
info: DocInfo,
structure: Result<Result<pdfcraft_cos::Document, pdfcraft_cos::CosError>, Box<dyn std::any::Any + Send>>,
render_password: Option<String>,
password: Option<&'a str>,
xfa: Option<XfaLayout>,
}
impl OpenContext {
/// Prepare a document without mutating the session that supplied this context.
pub fn prepare_open(
@@ -241,7 +252,8 @@ impl Session {
};
// 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);
let mut document =
Self::build_document(&context.trust, DocumentOpenData { name, path, bytes, info, structure: 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);
@@ -254,18 +266,8 @@ impl Session {
}
/// 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 {
fn build_document(trust: &Arc<TrustStore>, data: DocumentOpenData<'_>) -> Document {
let DocumentOpenData { name, path, bytes, mut info, structure: cos, render_password, password, xfa } = data;
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
let (mut editor, read_only_reason) = match cos {
Ok(Ok(cos)) => {
@@ -302,7 +304,7 @@ impl Session {
}
}
let display = display_bytes(editor.as_mut(), &bytes);
let renderer = RenderPool::new(display.clone(), render_threads(), config.clone());
let renderer = RenderPool::new(display.clone(), render_threads(), display_config(&config));
let (form, adopted) = editor
.as_ref()
.map(|e| {
+4 -4
View File
@@ -12,10 +12,10 @@ use crate::history::{Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts};
use crate::{
BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hidden, ImageResolution,
LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, SummarySort, TrustStore,
XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, comment_list, comment_summary, decode_text, display_bytes, guard, info_fields, js,
local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, parse_ymd, print, print_raster, render_threads,
scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices, use_layer_choices,
writable, xfa, xfa_sync_datasets,
XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, comment_list, comment_summary, decode_text, display_bytes, display_config, guard,
info_fields, js, local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, parse_ymd, print, print_raster,
render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices,
use_layer_choices, writable, xfa, xfa_sync_datasets,
};
mod editing;
+32 -12
View File
@@ -1,4 +1,12 @@
use super::*;
#[cfg(test)]
use super::check_refresh_panic;
use super::{
Arc, DocId, Document, Edit, EditCtx, EditError, Label, MAX_UNDO, ObjectKind, OnceLock, RenderPool, SaveOptions, Scope, Session,
XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, check_permission, comment_list, display_bytes, display_config, guard, info_fields, inspect, js, keys_after,
note_warnings, render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, undrawn_notices,
uses_scripts, write_incremental, xfa, xfa_sync_datasets,
};
use crate::{EditNotice, MarkKind};
impl Session {
/// Apply an edit. On success the previous state is undoable and the view data is refreshed.
@@ -159,13 +167,7 @@ impl Session {
if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) {
return Err(EditError::SignedRewrite(edit.label().to_string()));
}
// What this edit drew with `?` (a value or comment text its font can't draw, stored as
// typed): reported, never silent (AGENTS.md §4).
let notices = if matches!(scope_of(&edit), Scope::Form | Scope::Comments | Scope::Full) {
guard(|| undrawn_notices(&editor.cos, &next)).unwrap_or_default()
} else {
Vec::new()
};
let notices = edit_notices(&edit, &editor.cos, &next);
let previous = std::mem::replace(&mut editor.cos, next);
let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone());
let previous_keys = std::mem::replace(&mut editor.keys, keys);
@@ -373,7 +375,7 @@ impl Session {
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) };
doc.display = display_bytes(Some(editor), &bytes);
doc.signatures = Arc::new(signatures);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.bytes = bytes;
Ok(())
}
@@ -431,7 +433,7 @@ impl Session {
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) };
doc.display = display_bytes(Some(editor), &bytes);
doc.signatures = Arc::new(signatures);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.bytes = bytes;
doc.fonts_stale = true;
Ok(())
@@ -458,7 +460,7 @@ impl Session {
/// After a refresh failed: draw the display bytes it left (the original, once released),
/// so the page still shows a document.
pub(super) fn show_released(doc: &mut Document) {
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
/// Rebuild working bytes, inspection and renderer from the current edit state.
@@ -515,8 +517,26 @@ impl Session {
let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
doc.display = display_bytes(Some(editor), &bytes);
doc.signatures = Arc::new(signatures);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.bytes = bytes;
Ok(())
}
}
/// What `edit`, taking `prev` to `next`, wants said: never silent (AGENTS.md §4).
fn edit_notices(edit: &Edit, prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec<EditNotice> {
// What it drew with `?` (a value or comment text its font can't draw, stored as typed).
let mut notices: Vec<EditNotice> = if matches!(scope_of(edit), Scope::Form | Scope::Comments | Scope::Full) {
guard(|| undrawn_notices(prev, next)).unwrap_or_default().into_iter().map(EditNotice::Undrawn).collect()
} else {
Vec::new()
};
// Pages whose own white backdrop Remove background couldn't turn back on.
if matches!(edit, Edit::RemoveMarks { kind: MarkKind::Background }) {
let pages = guard(|| pdfcraft_edit::backdrops_left_off(next)).unwrap_or_default();
if !pages.is_empty() {
notices.push(EditNotice::BackdropsLeftOff { pages });
}
}
notices
}
+11 -5
View File
@@ -1,4 +1,8 @@
use super::*;
use super::{
Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions,
Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_raster, set_default_layer_state,
source_kind, writable, write_full,
};
impl Session {
/// The page count of another PDF (Replace Pages, Insert Pages dialogs).
@@ -64,10 +68,12 @@ impl Session {
if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) }
}
/// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium
/// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects
/// packed into compressed object streams. Returns the bytes and how many objects were
/// merged. The open document is not changed (Acrobat saves the reduced copy as a new file).
/// Reduce File Size: Acrobat's defaults (colour and grey images above 225 ppi to 150 ppi,
/// JPEG medium quality; black-and-white averaged down to 300 ppi above 450 ppi, and stored as
/// CCITT Group 4 whenever smaller; thumbnails dropped), identical resources merged, unused
/// objects dropped, objects packed into compressed object streams. Returns the bytes and how
/// many objects were merged. The open document is not changed (Acrobat saves the reduced copy
/// as a new file).
pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc<Vec<u8>>, usize), EditError> {
let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?;
Ok((bytes, report.merged))
+6 -3
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Arc, DocId, EditError, Keys, OpenError, RecoverySnapshot, RenderPool, SaveOptions, Session, SignOptions, TrustStore, display_bytes,
display_config, guard, render_threads, signatures_of, writable, write_full, write_incremental, xfa_sync_datasets,
};
impl Session {
/// The bytes to write for Save: an incremental update of the file as opened/last saved,
@@ -167,7 +170,7 @@ impl Session {
{
doc.config.reading_colors = old.config.reading_colors;
doc.config.hide_comments = old.config.hide_comments;
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
// Caches keyed by the edit generation (edit lines, images, measurements) must see a new
// one; there is nothing new to autosave.
@@ -246,7 +249,7 @@ impl Session {
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) };
doc.display = display_bytes(Some(editor), &doc.bytes);
doc.signatures = Arc::new(signatures);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
}
+6 -3
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Arc, BookmarkPage, BookmarkRow, DocId, Document, EditError, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, Session, SummarySort,
apply_layer_state, comment_summary, display_config, local_utc_offset, outline_rows, parse_ymd, render_threads, use_layer_choices,
};
impl Session {
pub fn new() -> Self {
@@ -243,7 +246,7 @@ impl Session {
return false;
}
doc.config.hide_comments = hide;
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.generation += 1;
true
}
@@ -256,7 +259,7 @@ impl Session {
return false;
}
doc.config.reading_colors = colors;
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.generation += 1;
true
}
+1 -1
View File
@@ -61,7 +61,7 @@ impl Document {
pdfcraft_annot::props(&editor.cos, page, index).map(|p| p.opacity).filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0) as f32;
let turn = pdfcraft_annot::picture_rotation(&editor.cos, page, index).map_err(|e| e.to_string())?;
let mut cos = editor.cos.clone();
pdfcraft_annot::delete_annotation(&mut cos, page, index).map_err(|e| e.to_string())?;
pdfcraft_annot::lift_annotation(&mut cos, page, index).map_err(|e| e.to_string())?;
let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?);
Ok(Some(ImageSignaturePreview { image, opacity, turn, page, bytes, config: self.config.clone() }))
})?
+18
View File
@@ -25,6 +25,24 @@ pub(crate) fn open_source_with(name: &str, bytes: &Arc<Vec<u8>>, password: Optio
source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}")))
}
/// Import the comments of `src` onto the pages of `doc` with the same numbers.
pub(crate) fn import_pdf_comments(doc: &mut pdfcraft_cos::Document, src: &pdfcraft_cos::Document) -> Result<(), EditError> {
if pdfcraft_organize::import_comments(doc, src)? == 0 {
return Err(pdfcraft_xfdf::DataError::NothingImported.into());
}
Ok(())
}
/// Parse another PDF to import comments from, whatever its permissions allow.
pub(crate) fn comment_source(bytes: &Arc<Vec<u8>>) -> Result<pdfcraft_cos::Document, SourceProblem> {
match std::panic::catch_unwind(|| pdfcraft_cos::Document::open(bytes.clone())) {
Ok(Ok(d)) => Ok(d),
Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password),
Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())),
Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())),
}
}
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),
+59 -2
View File
@@ -1,4 +1,5 @@
//! A stamp while it is moved or resized: the page without it, and the stamp alone.
//! A stamp while it is moved or resized: the page without it, and the stamp alone. Any other
//! comment while it is moved: the comment alone.
use std::sync::Arc;
@@ -73,7 +74,7 @@ impl Document {
return Ok(None);
}
let mut background = editor.cos.clone();
pdfcraft_annot::delete_annotation(&mut background, page, index).map_err(|e| e.to_string())?;
pdfcraft_annot::lift_annotation(&mut background, page, index).map_err(|e| e.to_string())?;
// The page beneath as it is displayed: comments without an appearance get theirs.
let background = pdfcraft_annot::with_missing_appearances(&background).unwrap_or(background);
let write = |cos: &pdfcraft_cos::Document| write_incremental(cos, &SaveOptions::default()).map(Arc::new).map_err(|e| e.to_string());
@@ -81,3 +82,59 @@ impl Document {
})?
}
}
/// Comments alone on a transparent page, for drawing them where they go while they are moved.
/// The page beneath keeps showing them where they were until the move is applied: rendering the page
/// without it would cost a whole page's render at every drag on a heavy plan. Read-only, as
/// [`StampPreview`] is.
pub struct CommentLayer {
page: usize,
bytes: Arc<Vec<u8>>,
config: RenderConfig,
}
impl CommentLayer {
/// One worker for the comments alone: their page has no content and no other annotations.
pub fn renderer(&self) -> RenderPool {
RenderPool::new(self.bytes.clone(), 1, self.transparent())
}
/// The comments alone, rendered synchronously with the renderer's normal pixel limits.
pub fn render(&self, scale: f32) -> Result<RenderedPage, String> {
if !scale.is_finite() || scale <= 0.0 {
return Err("preview scale must be finite and positive".into());
}
guard(|| {
let out =
PageRenderer::new(self.bytes.clone(), self.transparent()).render(RenderRequest { page: self.page, scale, ..Default::default() });
if let Some(e) = &out.error {
return Err(e.clone());
}
Ok(out)
})?
}
fn transparent(&self) -> RenderConfig {
RenderConfig { transparent: true, ..self.config.clone() }
}
}
impl Document {
/// The comments at `indices` on `page` alone (the ones moved together), drawn as on screen:
/// one without an appearance of its own gets the one the page view draws. `None` when one
/// of them doesn't exist or none has anything to draw.
pub fn comment_layer(&self, page: usize, indices: &[usize]) -> Result<Option<CommentLayer>, String> {
guard(|| {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
if indices.is_empty() || !indices.iter().all(|&i| self.info.annotations.iter().any(|a| a.page == page && a.index == i)) {
return Ok(None);
}
let mut alone = pdfcraft_annot::with_missing_appearances(&editor.cos).unwrap_or_else(|| editor.cos.clone());
if !pdfcraft_annot::isolate_annotations(&mut alone, page, indices).map_err(|e| e.to_string())? {
return Ok(None);
}
let bytes = write_incremental(&alone, &SaveOptions::default()).map(Arc::new).map_err(|e| e.to_string())?;
Ok(Some(CommentLayer { page, bytes, config: self.config.clone() }))
})?
}
}
File diff suppressed because it is too large Load Diff
+93 -8
View File
@@ -1,4 +1,5 @@
//! The page beneath an inline text editor, with the source glyphs removed on a COS clone.
//! The page beneath an inline text editor, with the source glyphs removed on a COS clone; and,
//! the same way, the page beneath a dragged added item or the marks an Update replaces.
use std::sync::Arc;
@@ -18,18 +19,75 @@ impl TextEditPreview {
}
pub fn render_background(&self, scale: f32) -> Result<RenderedPage, String> {
render_one(&self.bytes, &self.config, self.page, scale)
}
}
/// A document's pages without its marks of one kind ([`Document::without_marks`]).
pub struct UnmarkedPages {
bytes: Arc<Vec<u8>>,
config: RenderConfig,
/// Parsed on the first [`Self::render_page`], once (a copy that can't be parsed isn't tried
/// again), and kept: parsing costs about as much as reading the document.
parsed: Option<PageRenderer>,
/// How many times it was parsed.
#[cfg(test)]
builds: u64,
}
impl UnmarkedPages {
pub fn renderer(&self) -> RenderPool {
RenderPool::new(self.bytes.clone(), 1, self.config.clone())
}
/// `page` at `scale`, on this thread.
pub fn render_page(&mut self, page: usize, scale: f32) -> Result<RenderedPage, String> {
if !scale.is_finite() || scale <= 0.0 {
return Err("preview scale must be finite and positive".into());
}
guard(|| {
let mut renderer = PageRenderer::new(self.bytes.clone(), self.config.clone());
let out = renderer.render(RenderRequest { page: self.page, scale, ..Default::default() });
if let Some(e) = &out.error {
return Err(e.clone());
if self.parsed.is_none() {
self.parsed = Some(PageRenderer::new(self.bytes.clone(), self.config.clone()));
#[cfg(test)]
{
self.builds += 1;
}
Ok(out)
})?
}
let Some(renderer) = self.parsed.as_mut() else { return Err("the document could not be parsed".into()) };
let out = guard(|| renderer.render(RenderRequest { page, scale, ..Default::default() }));
let out = match out {
Ok(out) => out,
Err(e) => {
// A panic may have left it half-way: parse afresh next time.
self.parsed = None;
return Err(e);
}
};
if let Some(e) = &out.error {
return Err(e.clone());
}
Ok(out)
}
/// How many times [`Self::render_page`] has parsed the document.
#[cfg(test)]
pub(crate) fn parser_builds(&self) -> u64 {
self.builds
}
}
/// `page` of `bytes` at `scale`, on this thread.
fn render_one(bytes: &Arc<Vec<u8>>, config: &RenderConfig, page: usize, scale: f32) -> Result<RenderedPage, String> {
if !scale.is_finite() || scale <= 0.0 {
return Err("preview scale must be finite and positive".into());
}
guard(|| {
let mut renderer = PageRenderer::new(bytes.clone(), config.clone());
let out = renderer.render(RenderRequest { page, scale, ..Default::default() });
if let Some(e) = &out.error {
return Err(e.clone());
}
Ok(out)
})?
}
impl Document {
@@ -90,6 +148,33 @@ impl Document {
})?
}
/// The document without its marks of `kind`, on every page: what Update replaces them on, so
/// its preview doesn't draw the new marks over the old ones (or tint the old background).
/// Made once for all the pages a dialog steps through, as cloning and writing the document
/// costs about a save. `None` when the document has none, and the thumbnails already are
/// those pages. Read-only, like [`Self::text_edit_preview`].
pub fn without_marks(&self, kind: crate::MarkKind) -> Result<Option<UnmarkedPages>, String> {
if !self.marks().contains(&kind) {
return Ok(None);
}
guard(|| {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let mut cos = editor.cos.clone();
let n = pdfcraft_model::pages(&cos).len();
if pdfcraft_edit::remove_marks(&mut cos, &(0..n).collect::<Vec<_>>(), kind).map_err(|e| e.to_string())? == 0 {
return Ok(None);
}
let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?);
Ok(Some(UnmarkedPages {
bytes,
config: self.config.clone(),
parsed: None,
#[cfg(test)]
builds: 0,
}))
})?
}
/// An added item alone, on a transparent page: what follows the pointer while it is dragged,
/// so the page under its first place doesn't come along. Read-only, like
/// [`Self::added_content_preview`].
+229
View File
@@ -0,0 +1,229 @@
//! Bookmarks from text styles (#1693): a document's headings are often set apart only by how
//! they look (chapter titles in one font at one size, sections in another), not tagged as
//! headings, so New Bookmarks from Structure finds nothing. [`styles`] lists the styles the
//! text is set in; [`headings`] finds the lines in the styles chosen for each bookmark level.
//!
//! A style is a font (without its subset tag: `ABCDEF+Garamond` and `GHIJKL+Garamond` are
//! subsets of one font), the size the text is shown at, and whether it is bold and italic.
//! Lines are taken in reading order down each page: top to bottom, then left to right.
use pdfcraft_cos::Document;
use pdfcraft_organize::OutlineEntry;
/// How a line of text looks.
#[derive(Clone, Debug, PartialEq)]
pub struct TextStyle {
/// The font's name, without a subset tag.
pub font: String,
/// The size the text is shown at, in points (rounded to a tenth).
pub size: f64,
pub bold: bool,
pub italic: bool,
}
impl TextStyle {
/// Whether `other` is this style. Sizes within a quarter point match, as the same heading
/// size is often written with rounding differences (12 and 11.96).
pub(crate) fn matches(&self, other: &TextStyle) -> bool {
self.font == other.font && self.bold == other.bold && self.italic == other.italic && (self.size - other.size).abs() <= SIZE_TOLERANCE
}
}
/// How far apart two sizes may be and still be one style.
const SIZE_TOLERANCE: f64 = 0.25;
/// A style and how much text is set in it.
#[derive(Clone, Debug, PartialEq)]
pub struct TextStyleUse {
pub style: TextStyle,
/// Lines in this style.
pub lines: usize,
/// Pages they are on.
pub pages: usize,
/// The first line in it, cut to [`SAMPLE_CHARS`] characters.
pub sample: String,
}
/// The most pages read: a longer document is listed and searched by its first pages.
pub const MAX_PAGES: usize = 5000;
/// The most bookmarks made at once.
pub const MAX_BOOKMARKS: usize = 10_000;
/// The longest bookmark title kept, in characters (a heading run on into a paragraph).
const MAX_TITLE_CHARS: usize = 300;
/// The longest sample shown for a style.
const SAMPLE_CHARS: usize = 60;
/// The most styles listed, the largest first.
const MAX_STYLES: usize = 200;
/// One line of text in reading order.
struct Line {
page: usize,
text: String,
style: TextStyle,
/// Top, bottom, left and right of its box in user space.
top: f64,
bottom: f64,
left: f64,
right: f64,
}
/// `name` without a subset tag: six capital letters and a `+` (ISO 32000-2 §9.9.2).
fn without_subset_tag(name: &str) -> &str {
match name.split_once('+') {
Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) && !rest.is_empty() => rest,
_ => name,
}
}
/// The text lines of `doc`'s first [`MAX_PAGES`] pages, each page's in reading order. Text that
/// is only whitespace, or whose size can't be read, is left out.
fn lines(doc: &Document) -> Vec<Line> {
let pages = pdfcraft_organize::page_count(doc).unwrap_or(0).min(MAX_PAGES);
let mut out = Vec::new();
for page in 0..pages {
let Ok(found) = pdfcraft_edit::text_lines(doc, page) else { continue };
let mut on_page: Vec<Line> = found
.into_iter()
.filter_map(|l| {
let text = l.text.split_whitespace().collect::<Vec<_>>().join(" ");
// `size` is the size the line is shown at in user space (the `Tf` size times the
// text rendering matrix), so `1 Tf` with a 14× text matrix is 14.
let size = l.size;
let [x0, y0, x1, y1] = l.rect;
let ok = !text.is_empty() && size.is_finite() && size > 0.0 && [x0, y0, x1, y1].iter().all(|v| v.is_finite());
ok.then(|| Line {
page,
text,
style: TextStyle {
font: without_subset_tag(&l.base_font).to_string(),
size: (size * 10.0).round() / 10.0,
bold: l.bold,
italic: l.italic,
},
top: y0.max(y1),
bottom: y0.min(y1),
left: x0.min(x1),
right: x0.max(x1),
})
})
.collect();
// Down the page; along each row from the left. Boxes of one row differ by a fraction
// of a point, so rows are compared at a tenth of a point.
on_page.sort_by(|a, b| (b.top * 10.0).round().total_cmp(&(a.top * 10.0).round()).then(a.left.total_cmp(&b.left)));
out.extend(on_page);
}
out
}
/// The styles `doc`'s text is set in, the largest size first (then the most used): headings
/// are usually larger than the text around them.
pub fn styles(doc: &Document) -> Vec<TextStyleUse> {
let mut uses: Vec<(TextStyleUse, Option<usize>)> = Vec::new();
// The styles by font, weight, slant and size in quarter points, so a line is matched against
// the few styles near its size rather than every style found (a page can hold thousands).
// A size matches within a quarter point: the bucket's neighbours are looked at too.
let mut index: std::collections::HashMap<(String, bool, bool, i64), Vec<usize>> = std::collections::HashMap::new();
let bucket = |size: f64| (size / SIZE_TOLERANCE).floor() as i64;
for line in lines(doc) {
let key = |b: i64| (line.style.font.clone(), line.style.bold, line.style.italic, b);
let b = bucket(line.style.size);
let found = [b.saturating_sub(1), b, b.saturating_add(1)]
.into_iter()
.filter_map(|b| index.get(&key(b)))
.flatten()
.copied()
.find(|&i| uses.get(i).is_some_and(|(u, _)| u.style.matches(&line.style)));
match found.and_then(|i| uses.get_mut(i)) {
Some((u, last_page)) => {
u.lines = u.lines.saturating_add(1);
if *last_page != Some(line.page) {
u.pages = u.pages.saturating_add(1);
*last_page = Some(line.page);
}
}
None => {
let sample = line.text.chars().take(SAMPLE_CHARS).collect();
index.entry(key(b)).or_default().push(uses.len());
uses.push((TextStyleUse { style: line.style, lines: 1, pages: 1, sample }, Some(line.page)));
}
}
}
let mut uses: Vec<TextStyleUse> = uses.into_iter().map(|(u, _)| u).collect();
uses.sort_by(|a, b| b.style.size.total_cmp(&a.style.size).then(b.lines.cmp(&a.lines)));
uses.truncate(MAX_STYLES);
uses
}
/// The bookmarks for the lines in `levels`' styles: a line in `levels[0]`'s style is a level-1
/// bookmark, `levels[1]`'s a level-2 one under the level-1 bookmark before it, and so on. A
/// heading set on several lines (one after another in the same style, close below each other)
/// is one bookmark. Each goes to the top of its heading.
pub fn headings(doc: &Document, levels: &[TextStyle]) -> Vec<OutlineEntry> {
let mut entries: Vec<OutlineEntry> = Vec::new();
// The heading the previous line belonged to: its level, and the top, bottom, left and right
// of its last line.
let mut open: Option<(usize, f64, f64, f64, f64)> = None;
for line in lines(doc) {
let Some(level) = levels.iter().position(|s| s.matches(&line.style)) else {
open = None;
continue;
};
// A heading goes on to the next row down, close below its last line and under it. A line
// on the same row (two headings side by side), or in another column, is a heading of
// its own.
let continues = open.is_some_and(|(l, top, bottom, left, right)| {
let size = line.style.size;
let under = line.left < right && left < line.right;
l == level && line.top < top - size * 0.5 && bottom - line.top < size * 0.6 && under
}) && entries.last().is_some_and(|e| e.page == line.page);
if continues {
if let Some(e) = entries.last_mut()
&& e.title.chars().count() < MAX_TITLE_CHARS
{
e.title.push(' ');
e.title.push_str(&line.text);
e.title = e.title.chars().take(MAX_TITLE_CHARS).collect();
}
} else {
if entries.len() >= MAX_BOOKMARKS {
break;
}
// The level counts from 1; the position in `levels` is at most its length, which a
// caller keeps small (a few levels), so it fits a u8.
let level = u8::try_from(level.saturating_add(1)).unwrap_or(u8::MAX);
entries.push(OutlineEntry {
level,
title: line.text.chars().take(MAX_TITLE_CHARS).collect(),
page: line.page,
element: None,
top: Some(line.top),
});
}
open = Some((level, line.top, line.bottom, line.left, line.right));
}
entries
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subset_tags_are_left_out_of_the_font_name() {
assert_eq!(without_subset_tag("ABCDEF+Garamond-Bold"), "Garamond-Bold");
assert_eq!(without_subset_tag("Garamond-Bold"), "Garamond-Bold");
assert_eq!(without_subset_tag("ABCDE+Garamond"), "ABCDE+Garamond", "a tag is six letters");
assert_eq!(without_subset_tag("abcdef+Garamond"), "abcdef+Garamond", "capital letters");
assert_eq!(without_subset_tag("ABCDEF+"), "ABCDEF+", "nothing after the tag");
assert_eq!(without_subset_tag("ÄBCDEF+x"), "ÄBCDEF+x");
}
#[test]
fn styles_match_within_a_quarter_point() {
let s = |size| TextStyle { font: "Garamond".into(), size, bold: true, italic: false };
assert!(s(12.0).matches(&s(11.8)));
assert!(!s(12.0).matches(&s(11.7)));
assert!(!s(12.0).matches(&TextStyle { bold: false, ..s(12.0) }));
assert!(!s(12.0).matches(&TextStyle { font: "Times".into(), ..s(12.0) }));
}
}
+4 -2
View File
@@ -388,6 +388,7 @@ impl<'a, 'b> Runner<'a, 'b> {
}
}
/// Keep console `lines`, up to [`MAX_CONSOLE_PER_EVENT`] for one event; the rest are counted.
fn console(&mut self, lines: impl IntoIterator<Item = String>) {
for l in lines {
if self.run.out.console.len() < MAX_CONSOLE_PER_EVENT {
@@ -398,7 +399,8 @@ impl<'a, 'b> Runner<'a, 'b> {
}
}
fn alert(&mut self, m: String) {
/// Show message `m`, up to [`MAX_ALERTS_PER_EVENT`] for one event; the rest are counted.
fn alert(&mut self, m: crate::js::Message) {
if self.run.out.alerts.len() < MAX_ALERTS_PER_EVENT {
self.run.out.alerts.push(m);
} else {
@@ -847,7 +849,7 @@ pub(crate) fn on_changes(doc: &mut pdfcraft_cos::Document, tpl: &Template, names
for ev in r.events_of(&som, "validate") {
let Some(o) = r.run(&ev, &value)? else { continue };
if (o.result_bool == Some(false) || o.error.is_some()) && !value.trim().is_empty() {
r.alert(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid")));
r.alert(crate::js::Message::plain(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid"))));
}
}
}