mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 18:31:10 -06:00
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:
@@ -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"),
|
||||
|
||||
@@ -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,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)]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() }))
|
||||
})?
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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() }))
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
+771
-84
File diff suppressed because it is too large
Load Diff
@@ -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`].
|
||||
|
||||
@@ -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) }));
|
||||
}
|
||||
}
|
||||
@@ -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"))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user