mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 10:39:01 -06:00
Add 'Replace pages…' action to the Organize Pages toolbar, organize grid thumbnail context menu, and Pages panel context menu to replace selected pages. - Add ViewAction::ReplacePages triggering replace_pages_dialog() - Expose 'Replace pages…' button with 'replace' icon on the organize toolbar - Expose 'Replace pages…' on the organize grid and Pages panel thumbnail context menus - Update parity evidence for organize.replace-pages - Add integration tests for toolbar and context menu replace actions in crates/ui-egui/tests/editing.rs Co-authored-by: KrzysztofC <krzysztofc@localhost-live.lan>
201 lines
8.8 KiB
Rust
201 lines
8.8 KiB
Rust
//! Compare files: the text differences between two open documents, a report, and the
|
|
//! differences marked as comments in the newer one.
|
|
|
|
use std::sync::Arc;
|
|
|
|
pub use pdfcraft_compare::{Change, Comparison, Kind, Side};
|
|
|
|
use crate::{DocId, Edit, EditError, Markup, NewAnnotation, NoteIcon, Session, Shape};
|
|
|
|
/// Acrobat's compare colours: replaced blue, inserted green, deleted red.
|
|
pub fn colour(kind: Kind) -> crate::Rgb {
|
|
match kind {
|
|
Kind::Replaced => [0.2, 0.45, 0.95],
|
|
Kind::Inserted => [0.2, 0.75, 0.3],
|
|
Kind::Deleted => [0.9, 0.25, 0.25],
|
|
}
|
|
}
|
|
|
|
impl crate::Document {
|
|
/// Standards ▸ Verify PDF/A: the rules the document breaks for `level`.
|
|
pub fn pdfa_verify(&self, level: pdfcraft_preflight::Level) -> Vec<pdfcraft_preflight::Issue> {
|
|
self.editor.as_ref().map(|e| pdfcraft_preflight::verify(&e.cos, level)).unwrap_or_default()
|
|
}
|
|
|
|
/// The standards the document declares (Standards panel).
|
|
pub fn standards(&self) -> pdfcraft_preflight::Declared {
|
|
self.editor.as_ref().map(|e| pdfcraft_preflight::declared(&e.cos)).unwrap_or_default()
|
|
}
|
|
|
|
/// Every word of the document in reading order, with page and box.
|
|
pub fn words(&self) -> Vec<pdfcraft_compare::Word> {
|
|
let config = pdfcraft_render::RenderConfig { password: self.password.as_deref().map(Arc::from), ..Default::default() };
|
|
let mut r = pdfcraft_render::PageRenderer::new(self.bytes.clone(), config);
|
|
let mut out = Vec::new();
|
|
for (page, info) in self.info.pages.iter().enumerate() {
|
|
let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() });
|
|
if let Some(t) = res.text {
|
|
out.extend(crate::js::page_words(&t, info).into_iter().map(|(text, rect)| pdfcraft_compare::Word { text, page, rect }));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
impl Session {
|
|
/// Compare files: the text differences from document `old` to document `new`.
|
|
pub fn compare(&self, old: DocId, new: DocId) -> Result<Comparison, EditError> {
|
|
let a = self.get(old).ok_or(EditError::NoDocument)?;
|
|
let b = self.get(new).ok_or(EditError::NoDocument)?;
|
|
Ok(pdfcraft_compare::compare(&a.words(), &b.words()))
|
|
}
|
|
|
|
/// Visual compare: regions where page n of `new` looks different from page n of `old`
|
|
/// (rendered at `dpi`), as (page, user-space box in `new`).
|
|
pub fn compare_visual(&self, old: DocId, new: DocId, dpi: f32) -> Result<Vec<(usize, [f64; 4])>, EditError> {
|
|
let a = self.get(old).ok_or(EditError::NoDocument)?;
|
|
let b = self.get(new).ok_or(EditError::NoDocument)?;
|
|
let renderer = |d: &crate::Document| {
|
|
pdfcraft_render::PageRenderer::new(
|
|
d.bytes.clone(),
|
|
pdfcraft_render::RenderConfig { password: d.password.as_deref().map(Arc::from), ..Default::default() },
|
|
)
|
|
};
|
|
let (mut ra, mut rb) = (renderer(a), renderer(b));
|
|
let scale = dpi.clamp(18.0, 150.0) / 72.0;
|
|
let mut out = Vec::new();
|
|
for (page, info) in b.info.pages.iter().enumerate().take(a.info.pages.len()) {
|
|
let req = pdfcraft_render::RenderRequest { page, scale, ..Default::default() };
|
|
let (x, y) = (ra.render(req), rb.render(req));
|
|
if x.error.is_some() || y.error.is_some() {
|
|
continue;
|
|
}
|
|
let s = y.width as f32 / info.width.max(1e-3);
|
|
for r in pdfcraft_compare::visual_regions((&y.rgba, y.width, y.height), (&x.rgba, x.width, x.height), 24) {
|
|
let p = info.view_to_user(r[0] as f32 / s, r[1] as f32 / s);
|
|
let q = info.view_to_user(r[2] as f32 / s, r[3] as f32 / s);
|
|
out.push((page, [p[0].min(q[0]) as f64, p[1].min(q[1]) as f64, p[0].max(q[0]) as f64, p[1].max(q[1]) as f64]));
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// The compare report as a new PDF (not opened).
|
|
pub fn compare_report(&self, old: DocId, new: DocId) -> Result<Arc<Vec<u8>>, EditError> {
|
|
let c = self.compare(old, new)?;
|
|
let name = |id| self.get(id).map(|d| d.name.clone()).unwrap_or_default();
|
|
self.create_report("Compare Report", &pdfcraft_compare::report(&c, &name(old), &name(new)))
|
|
}
|
|
|
|
/// Mark the differences in `new` as comments: highlights over replaced and inserted text
|
|
/// (blue, green) and a note where text was deleted (red), authored "Compare". One undoable
|
|
/// step; returns how many comments were added.
|
|
pub fn mark_differences(&mut self, old: DocId, new: DocId) -> Result<usize, EditError> {
|
|
let c = self.compare(old, new)?;
|
|
let mut edits = Vec::new();
|
|
for ch in &c.changes {
|
|
let color = colour(ch.kind);
|
|
let contents = match ch.kind {
|
|
Kind::Replaced => format!("Replaced: \"{}\" with \"{}\"", ch.old.text, ch.new.text),
|
|
Kind::Inserted => format!("Inserted: \"{}\"", ch.new.text),
|
|
Kind::Deleted => format!("Deleted: \"{}\"", ch.old.text),
|
|
};
|
|
let shape = if ch.new.rects.is_empty() {
|
|
let Some(r) = ch.new.near else { continue };
|
|
Shape::Note { at: [r[0], r[3]], icon: NoteIcon::Note }
|
|
} else {
|
|
Shape::TextMarkup {
|
|
kind: Markup::Highlight,
|
|
quads: ch.new.rects.iter().map(|r| [r[0], r[3], r[2], r[3], r[0], r[1], r[2], r[1]]).collect(),
|
|
}
|
|
};
|
|
let mut style = crate::Style::default_for(&shape);
|
|
style.color = color;
|
|
edits.push(Edit::AddAnnotation(NewAnnotation { page: ch.new.page, shape, style, contents, author: "Compare".into() }));
|
|
}
|
|
let n = edits.len();
|
|
if n > 0 {
|
|
self.apply(new, Edit::Batch { label: "Mark differences".into(), edits })?;
|
|
}
|
|
Ok(n)
|
|
}
|
|
}
|
|
|
|
/// Export a PDF ▸ Word, HTML or RTF.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum OfficeFormat {
|
|
Docx,
|
|
Html,
|
|
Rtf,
|
|
}
|
|
|
|
impl OfficeFormat {
|
|
pub fn extension(self) -> &'static str {
|
|
match self {
|
|
OfficeFormat::Docx => "docx",
|
|
OfficeFormat::Html => "html",
|
|
OfficeFormat::Rtf => "rtf",
|
|
}
|
|
}
|
|
|
|
pub fn from_extension(ext: &str) -> Option<OfficeFormat> {
|
|
match ext.to_ascii_lowercase().as_str() {
|
|
"docx" => Some(OfficeFormat::Docx),
|
|
"html" | "htm" => Some(OfficeFormat::Html),
|
|
"rtf" => Some(OfficeFormat::Rtf),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl crate::Document {
|
|
/// The pages as paragraphs and images (for Word, HTML and RTF export), including what
|
|
/// form XObjects draw.
|
|
pub fn export_pages(&self) -> Vec<pdfcraft_export::Page> {
|
|
let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return Vec::new() };
|
|
self.info
|
|
.pages
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, info)| {
|
|
let blocks = pdfcraft_edit::reading_blocks(cos, i)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter(|b| !b.text.trim().is_empty())
|
|
.map(|b| {
|
|
let f = b.base_font.to_ascii_lowercase();
|
|
pdfcraft_export::Block {
|
|
text: b.text,
|
|
rect: b.rect,
|
|
size: b.size,
|
|
bold: f.contains("bold") || f.contains("black") || f.contains("heavy"),
|
|
italic: f.contains("italic") || f.contains("oblique"),
|
|
color: b.color,
|
|
}
|
|
})
|
|
.collect();
|
|
let images = pdfcraft_edit::reading_images(cos, i)
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.filter_map(|im| {
|
|
let (ext, bytes) = pdfcraft_create::image_file(cos, im.object?).ok()?;
|
|
Some(pdfcraft_export::Image { ext: if ext == "jpg" { "jpg" } else { "png" }, bytes, rect: im.rect })
|
|
})
|
|
.collect();
|
|
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images }
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The document as a Word, HTML or RTF file.
|
|
pub fn export_office(&self, format: OfficeFormat) -> Vec<u8> {
|
|
let pages = self.export_pages();
|
|
let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string());
|
|
match format {
|
|
OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title),
|
|
OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(),
|
|
OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(),
|
|
}
|
|
}
|
|
}
|