Squashed from work: - score: a headless music engraving, playback and notation engine - score: the notation app — pianist mode, editing, playback - piano_model: it was a plucked string by construction, and 20 voicings - score: one document you can pan, zoom and navigate - piano_model: the body tap was a click, and the objective was rewarding noise - score: add the sound panel and library modules - piano_model: a second engine, and the attack that finally sounded right - score: two instruments, reverb and brightness — and the rest of the panel gone - score model: a note remembers how it was struck, and the score remembers the pedal - score import: keep the velocities and the pedal the file was carrying - score playback: play the performance, not a flattened copy of it - score ui: the music list moves to the sidebar, and the view stops fighting itself - score: the application ships its font and eight performances - piano_model: a limiter that rides the music, so the knee stops shaping chords - piano_model: the forte bell was the treble's dynamic slope, and the bass was dying at its own prompt rate - piano_model: the bridge decides each partial's decay, and a fixed multiplier cannot say that - piano_model: each partial gets its own two coupled modes, from the eigen algebra - piano_model: a median that fell between the peaks made every bass partial a drain - score-ai: LocalBroker — the seam's in-process implementation over the session engine (aicore P8) - client + chat dispatcher: the dead wire comes out (aicore P7/P8) - score_pdf: the score model grew a pedal map — the pdf importer initialises it - libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly - score app: the shipped-piece test speaks the PERFORMANCES table - zero-warning sweep, round three — the model lanes and the deep examples
906 lines
31 KiB
Rust
906 lines
31 KiB
Rust
//! Incremental PDF export preserving the original byte prefix.
|
|
|
|
use crate::display::{page_object_refs, Color, PathCommand, PathPaint};
|
|
use crate::geometry::{Point, Rect};
|
|
use crate::provenance::canonical_provenance_json;
|
|
use crate::sha256::sha256;
|
|
use crate::splice::{ErasePlan, OperatorEdit, PaintCommand, SpliceWarning};
|
|
use crate::RecognizedDocument;
|
|
use makepad_pdf_parse::{ObjRef, PdfDict, PdfDocument, PdfObj};
|
|
use std::collections::BTreeMap;
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct PdfExportOptions {
|
|
pub include_provenance: bool,
|
|
pub fail_on_signatures: bool,
|
|
}
|
|
|
|
impl Default for PdfExportOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
include_provenance: true,
|
|
fail_on_signatures: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct ExportValidation {
|
|
pub original_prefix_identical: bool,
|
|
pub incremental_revision: bool,
|
|
pub reparsed: bool,
|
|
pub page_count: usize,
|
|
pub warnings: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct ExportedPdf {
|
|
pub bytes: Vec<u8>,
|
|
pub original_len: usize,
|
|
pub appended_len: usize,
|
|
pub provenance_json: Vec<u8>,
|
|
pub created_object_hashes: Vec<(u32, [u8; 32])>,
|
|
pub validation: ExportValidation,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum PdfExportError {
|
|
NoEdits,
|
|
EncryptedDocument,
|
|
SignedDocument,
|
|
ReflowApprovalRequired,
|
|
ScanPatchUnavailable,
|
|
InvalidOriginal(String),
|
|
Validation(String),
|
|
}
|
|
|
|
impl std::fmt::Display for PdfExportError {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::NoEdits => formatter.write_str("document has no applied edits"),
|
|
Self::EncryptedDocument => {
|
|
formatter.write_str("encrypted PDFs are not modified without a permission policy")
|
|
}
|
|
Self::SignedDocument => formatter.write_str(
|
|
"PDF carries signature/certification evidence; export is fail-closed",
|
|
),
|
|
Self::ReflowApprovalRequired => {
|
|
formatter.write_str("system reflow requires explicit approval")
|
|
}
|
|
Self::ScanPatchUnavailable => {
|
|
formatter.write_str("scan recognition/inpainting is not available")
|
|
}
|
|
Self::InvalidOriginal(message) => write!(formatter, "invalid original PDF: {message}"),
|
|
Self::Validation(message) => write!(formatter, "export validation failed: {message}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for PdfExportError {}
|
|
|
|
#[derive(Clone)]
|
|
struct RevisionObject {
|
|
reference: ObjRef,
|
|
body: Vec<u8>,
|
|
}
|
|
|
|
pub fn export_pdf(
|
|
document: &RecognizedDocument,
|
|
options: &PdfExportOptions,
|
|
) -> Result<ExportedPdf, PdfExportError> {
|
|
if document.edits.edits.is_empty() {
|
|
return Err(PdfExportError::NoEdits);
|
|
}
|
|
if document
|
|
.edits
|
|
.edits
|
|
.iter()
|
|
.any(|edit| edit.plan.requires_explicit_approval)
|
|
{
|
|
return Err(PdfExportError::ReflowApprovalRequired);
|
|
}
|
|
if document.edits.edits.iter().any(|edit| {
|
|
matches!(edit.plan.erase, ErasePlan::RasterPatchUnavailable)
|
|
}) {
|
|
return Err(PdfExportError::ScanPatchUnavailable);
|
|
}
|
|
if options.fail_on_signatures
|
|
&& (contains(&document.original, b"/ByteRange")
|
|
|| contains(&document.original, b"/DocMDP"))
|
|
{
|
|
return Err(PdfExportError::SignedDocument);
|
|
}
|
|
|
|
let compatible = crate::display::parser_compatible_bytes(&document.original);
|
|
let parser_bytes = compatible.as_deref().unwrap_or(&document.original);
|
|
let mut parser = PdfDocument::parse(parser_bytes)
|
|
.map_err(|error| PdfExportError::InvalidOriginal(error.to_string()))?;
|
|
if parser.trailer().get("Encrypt").is_some() {
|
|
return Err(PdfExportError::EncryptedDocument);
|
|
}
|
|
let root = parser
|
|
.trailer()
|
|
.get_ref("Root")
|
|
.ok_or_else(|| PdfExportError::InvalidOriginal("trailer has no /Root".to_string()))?;
|
|
let original_size = parser.trailer().get_int("Size").unwrap_or(1).max(1) as u32;
|
|
let previous_xref = last_startxref(&document.original).ok_or_else(|| {
|
|
PdfExportError::InvalidOriginal("startxref was not found".to_string())
|
|
})?;
|
|
let page_refs = page_object_refs(&mut parser)
|
|
.map_err(|error| PdfExportError::InvalidOriginal(error.to_string()))?;
|
|
|
|
let mut next_object = original_size;
|
|
let mut revisions = Vec::new();
|
|
let mut created_hashes = Vec::new();
|
|
let mut plans_by_page: BTreeMap<u32, Vec<_>> = BTreeMap::new();
|
|
for edit in &document.edits.edits {
|
|
plans_by_page
|
|
.entry(edit.plan.page.0)
|
|
.or_default()
|
|
.push(&edit.plan);
|
|
}
|
|
|
|
for (page_index, plans) in plans_by_page {
|
|
let page = document.pages.get(page_index as usize).ok_or_else(|| {
|
|
PdfExportError::InvalidOriginal(format!("recognized page {page_index} is absent"))
|
|
})?;
|
|
let page_ref = *page_refs.get(page_index as usize).ok_or_else(|| {
|
|
PdfExportError::InvalidOriginal(format!("PDF page {page_index} is absent"))
|
|
})?;
|
|
let page_object = parser
|
|
.resolve_ref(page_ref)
|
|
.map_err(|error| PdfExportError::InvalidOriginal(error.to_string()))?;
|
|
let page_dict = page_object
|
|
.as_dict()
|
|
.cloned()
|
|
.ok_or_else(|| PdfExportError::InvalidOriginal("page is not a dictionary".to_string()))?;
|
|
let original_resources = inherited_resources(&mut parser, &page_dict)
|
|
.map_err(PdfExportError::InvalidOriginal)?;
|
|
|
|
let form_ref = allocate(&mut next_object);
|
|
let resources_ref = allocate(&mut next_object);
|
|
let content_ref = allocate(&mut next_object);
|
|
let form_name = format!("MPOriginal{page_index}");
|
|
let exact_edits = plans
|
|
.iter()
|
|
.flat_map(|plan| match &plan.erase {
|
|
ErasePlan::OperatorRewrite { edits, .. } => edits.iter().collect::<Vec<_>>(),
|
|
_ => Vec::new(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let original_content = rewrite_content_streams(&page.display, &exact_edits)
|
|
.map_err(PdfExportError::InvalidOriginal)?;
|
|
let form_dict = format!(
|
|
"/Type /XObject /Subtype /Form /BBox [{} {} {} {}] /Resources {}",
|
|
number(page.display.media_box.min_x),
|
|
number(page.display.media_box.min_y),
|
|
number(page.display.media_box.max_x),
|
|
number(page.display.media_box.max_y),
|
|
object_inline(&original_resources)
|
|
);
|
|
push_new_object(
|
|
&mut revisions,
|
|
&mut created_hashes,
|
|
form_ref,
|
|
stream_body(&form_dict, &original_content),
|
|
);
|
|
|
|
let merged_resources = merge_xobject_resources(
|
|
&mut parser,
|
|
&original_resources,
|
|
&form_name,
|
|
form_ref,
|
|
)
|
|
.map_err(PdfExportError::InvalidOriginal)?;
|
|
push_new_object(
|
|
&mut revisions,
|
|
&mut created_hashes,
|
|
resources_ref,
|
|
object_body(&PdfObj::Dict(merged_resources)),
|
|
);
|
|
|
|
let patches = merge_rectangles(
|
|
plans
|
|
.iter()
|
|
.filter(|plan| matches!(plan.erase, ErasePlan::ClippedOriginalForm { .. }))
|
|
.map(|plan| plan.patch_bounds)
|
|
.collect(),
|
|
);
|
|
let mut patch_content = Vec::new();
|
|
patch_content.extend_from_slice(b"q\n");
|
|
if !patches.is_empty() {
|
|
write_rect_path(&mut patch_content, page.display.crop_box);
|
|
for patch in &patches {
|
|
write_rect_path(&mut patch_content, *patch);
|
|
}
|
|
patch_content.extend_from_slice(b"W* n\n");
|
|
}
|
|
patch_content.extend_from_slice(format!("/{form_name} Do\nQ\n").as_bytes());
|
|
for plan in plans {
|
|
let commands = match &plan.erase {
|
|
ErasePlan::OperatorRewrite { replacement, .. }
|
|
| ErasePlan::ClippedOriginalForm { replacement }
|
|
| ErasePlan::OverlayOnly { replacement } => replacement,
|
|
ErasePlan::RasterPatchUnavailable => return Err(PdfExportError::ScanPatchUnavailable),
|
|
};
|
|
write_paint_commands(&mut patch_content, commands);
|
|
}
|
|
push_new_object(
|
|
&mut revisions,
|
|
&mut created_hashes,
|
|
content_ref,
|
|
stream_body("", &patch_content),
|
|
);
|
|
|
|
let mut revised_page = page_dict;
|
|
revised_page
|
|
.map
|
|
.insert("Contents".to_string(), PdfObj::Ref(content_ref));
|
|
revised_page
|
|
.map
|
|
.insert("Resources".to_string(), PdfObj::Ref(resources_ref));
|
|
revisions.push(RevisionObject {
|
|
reference: page_ref,
|
|
body: object_body(&PdfObj::Dict(revised_page)),
|
|
});
|
|
}
|
|
|
|
let mut provenance_json = Vec::new();
|
|
if options.include_provenance {
|
|
provenance_json = canonical_provenance_json(document, &created_hashes);
|
|
let embedded_ref = allocate(&mut next_object);
|
|
let filespec_ref = allocate(&mut next_object);
|
|
let names_ref = allocate(&mut next_object);
|
|
let metadata_ref = allocate(&mut next_object);
|
|
push_new_object(
|
|
&mut revisions,
|
|
&mut created_hashes,
|
|
embedded_ref,
|
|
stream_body("/Type /EmbeddedFile /Subtype /application#2Fjson", &provenance_json),
|
|
);
|
|
let mut filespec = PdfDict::new();
|
|
filespec
|
|
.map
|
|
.insert("Type".to_string(), PdfObj::Name("Filespec".to_string()));
|
|
filespec.map.insert(
|
|
"F".to_string(),
|
|
PdfObj::Str(b"makepad-score-edit.json".to_vec()),
|
|
);
|
|
filespec.map.insert(
|
|
"UF".to_string(),
|
|
PdfObj::Str(b"makepad-score-edit.json".to_vec()),
|
|
);
|
|
let mut ef = PdfDict::new();
|
|
ef.map.insert("F".to_string(), PdfObj::Ref(embedded_ref));
|
|
filespec.map.insert("EF".to_string(), PdfObj::Dict(ef));
|
|
filespec.map.insert(
|
|
"AFRelationship".to_string(),
|
|
PdfObj::Name("Data".to_string()),
|
|
);
|
|
push_new_object(
|
|
&mut revisions,
|
|
&mut created_hashes,
|
|
filespec_ref,
|
|
object_body(&PdfObj::Dict(filespec)),
|
|
);
|
|
let mut embedded_names = PdfDict::new();
|
|
embedded_names.map.insert(
|
|
"Names".to_string(),
|
|
PdfObj::Array(vec![
|
|
PdfObj::Str(b"makepad-score-edit.json".to_vec()),
|
|
PdfObj::Ref(filespec_ref),
|
|
]),
|
|
);
|
|
push_new_object(
|
|
&mut revisions,
|
|
&mut created_hashes,
|
|
names_ref,
|
|
object_body(&PdfObj::Dict(embedded_names)),
|
|
);
|
|
let xmp = xmp_summary(&document.original_sha256, document.edits.edits.len());
|
|
push_new_object(
|
|
&mut revisions,
|
|
&mut created_hashes,
|
|
metadata_ref,
|
|
stream_body("/Type /Metadata /Subtype /XML", xmp.as_bytes()),
|
|
);
|
|
let catalog_object = parser
|
|
.resolve_ref(root)
|
|
.map_err(|error| PdfExportError::InvalidOriginal(error.to_string()))?;
|
|
let mut catalog = catalog_object
|
|
.as_dict()
|
|
.cloned()
|
|
.ok_or_else(|| PdfExportError::InvalidOriginal("catalog is not a dictionary".to_string()))?;
|
|
let mut names = match catalog.get("Names") {
|
|
Some(value) => parser
|
|
.resolve(value)
|
|
.ok()
|
|
.and_then(|value| value.as_dict().cloned())
|
|
.unwrap_or_default(),
|
|
None => PdfDict::new(),
|
|
};
|
|
names
|
|
.map
|
|
.insert("EmbeddedFiles".to_string(), PdfObj::Ref(names_ref));
|
|
catalog.map.insert("Names".to_string(), PdfObj::Dict(names));
|
|
catalog
|
|
.map
|
|
.insert("Metadata".to_string(), PdfObj::Ref(metadata_ref));
|
|
catalog
|
|
.map
|
|
.insert("AF".to_string(), PdfObj::Array(vec![PdfObj::Ref(filespec_ref)]));
|
|
revisions.push(RevisionObject {
|
|
reference: root,
|
|
body: object_body(&PdfObj::Dict(catalog)),
|
|
});
|
|
}
|
|
|
|
revisions.sort_by_key(|object| (object.reference.num, object.reference.gen));
|
|
let mut bytes = document.original.to_vec();
|
|
if !bytes.ends_with(b"\n") {
|
|
bytes.push(b'\n');
|
|
}
|
|
let mut xref_entries = Vec::new();
|
|
for object in &revisions {
|
|
let offset = bytes.len();
|
|
bytes.extend_from_slice(
|
|
format!("{} {} obj\n", object.reference.num, object.reference.gen).as_bytes(),
|
|
);
|
|
bytes.extend_from_slice(&object.body);
|
|
if !bytes.ends_with(b"\n") {
|
|
bytes.push(b'\n');
|
|
}
|
|
bytes.extend_from_slice(b"endobj\n");
|
|
xref_entries.push((object.reference, offset));
|
|
}
|
|
let xref_offset = bytes.len();
|
|
bytes.extend_from_slice(b"xref\n");
|
|
for (reference, offset) in &xref_entries {
|
|
bytes.extend_from_slice(format!("{} 1\n", reference.num).as_bytes());
|
|
bytes.extend_from_slice(format!("{:010} {:05} n \n", offset, reference.gen).as_bytes());
|
|
}
|
|
let size = next_object.max(original_size);
|
|
bytes.extend_from_slice(
|
|
format!(
|
|
"trailer\n<< /Size {size} /Root {} {} R /Prev {previous_xref} >>\nstartxref\n{xref_offset}\n%%EOF\n",
|
|
root.num, root.gen
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
|
|
let original_prefix_identical = bytes.starts_with(&document.original);
|
|
let compatible_export = crate::display::parser_compatible_bytes(&bytes);
|
|
let exported_parser_bytes = compatible_export.as_deref().unwrap_or(&bytes);
|
|
let (reparsed, page_count) = match PdfDocument::parse(exported_parser_bytes) {
|
|
Ok(parsed) => (true, parsed.page_count()),
|
|
Err(error) => return Err(PdfExportError::Validation(error.to_string())),
|
|
};
|
|
let mut warnings = Vec::new();
|
|
if !options.fail_on_signatures
|
|
&& (contains(&document.original, b"/ByteRange")
|
|
|| contains(&document.original, b"/DocMDP"))
|
|
{
|
|
warnings.push("the incremental edit is not covered by the original signature".to_string());
|
|
}
|
|
if document
|
|
.edits
|
|
.edits
|
|
.iter()
|
|
.any(|edit| edit.plan.warnings.contains(&SpliceWarning::FontSubstituted))
|
|
{
|
|
warnings.push("patch uses OFL-compatible substitute geometry".to_string());
|
|
}
|
|
Ok(ExportedPdf {
|
|
original_len: document.original.len(),
|
|
appended_len: bytes.len() - document.original.len(),
|
|
provenance_json,
|
|
created_object_hashes: created_hashes,
|
|
validation: ExportValidation {
|
|
original_prefix_identical,
|
|
incremental_revision: true,
|
|
reparsed,
|
|
page_count,
|
|
warnings,
|
|
},
|
|
bytes,
|
|
})
|
|
}
|
|
|
|
fn rewrite_content_streams(
|
|
display: &crate::display::DisplayList,
|
|
edits: &[&OperatorEdit],
|
|
) -> Result<Vec<u8>, String> {
|
|
let mut pending: BTreeMap<(u32, u16, u16, u32, u32), &[u8]> = BTreeMap::new();
|
|
for edit in edits {
|
|
if !edit.source.form_chain.is_empty() {
|
|
return Err("exact operator rewrite cannot target an operator inside a Form".to_string());
|
|
}
|
|
let key = (
|
|
edit.source.object.num,
|
|
edit.source.object.gen,
|
|
edit.source.stream_index,
|
|
edit.source.decoded_bytes.start,
|
|
edit.source.decoded_bytes.end,
|
|
);
|
|
match pending.insert(key, &edit.replacement) {
|
|
Some(existing) if existing != edit.replacement => {
|
|
return Err("conflicting replacements target one PDF operator".to_string())
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let mut output = Vec::new();
|
|
for stream in &display.content_streams {
|
|
let mut stream_edits = pending
|
|
.iter()
|
|
.filter(|((object, generation, stream_index, _, _), _)| {
|
|
*object == stream.object.num
|
|
&& *generation == stream.object.gen
|
|
&& *stream_index == stream.stream_index
|
|
})
|
|
.map(|((_, _, _, start, end), replacement)| (*start, *end, *replacement))
|
|
.collect::<Vec<_>>();
|
|
stream_edits.sort_by_key(|(start, end, _)| (*start, *end));
|
|
let mut cursor = 0_usize;
|
|
for (start, end, replacement) in stream_edits {
|
|
let (start, end) = (start as usize, end as usize);
|
|
if start < cursor || end < start || end > stream.decoded.len() {
|
|
return Err("operator rewrite has an invalid or overlapping byte range".to_string());
|
|
}
|
|
output.extend_from_slice(&stream.decoded[cursor..start]);
|
|
output.extend_from_slice(replacement);
|
|
cursor = end;
|
|
pending.remove(&(
|
|
stream.object.num,
|
|
stream.object.gen,
|
|
stream.stream_index,
|
|
start as u32,
|
|
end as u32,
|
|
));
|
|
}
|
|
output.extend_from_slice(&stream.decoded[cursor..]);
|
|
output.push(b'\n');
|
|
}
|
|
if !pending.is_empty() {
|
|
return Err("operator rewrite source stream was not found on the page".to_string());
|
|
}
|
|
Ok(output)
|
|
}
|
|
|
|
fn allocate(next: &mut u32) -> ObjRef {
|
|
let value = ObjRef { num: *next, gen: 0 };
|
|
*next = next.saturating_add(1);
|
|
value
|
|
}
|
|
|
|
fn push_new_object(
|
|
revisions: &mut Vec<RevisionObject>,
|
|
hashes: &mut Vec<(u32, [u8; 32])>,
|
|
reference: ObjRef,
|
|
body: Vec<u8>,
|
|
) {
|
|
hashes.push((reference.num, sha256(&body)));
|
|
revisions.push(RevisionObject { reference, body });
|
|
}
|
|
|
|
fn inherited_resources(
|
|
parser: &mut PdfDocument<'_>,
|
|
page: &PdfDict,
|
|
) -> Result<PdfObj, String> {
|
|
if let Some(resources) = page.get("Resources") {
|
|
return Ok(resources.clone());
|
|
}
|
|
let mut parent = page.get("Parent").cloned();
|
|
for _ in 0..128 {
|
|
let Some(parent_object) = parent else {
|
|
break;
|
|
};
|
|
let resolved = parser.resolve(&parent_object).map_err(|error| error.to_string())?;
|
|
let dict = resolved
|
|
.as_dict()
|
|
.ok_or_else(|| "page parent is not a dictionary".to_string())?;
|
|
if let Some(resources) = dict.get("Resources") {
|
|
return Ok(resources.clone());
|
|
}
|
|
parent = dict.get("Parent").cloned();
|
|
}
|
|
Ok(PdfObj::Dict(PdfDict::new()))
|
|
}
|
|
|
|
fn merge_xobject_resources(
|
|
parser: &mut PdfDocument<'_>,
|
|
resources: &PdfObj,
|
|
name: &str,
|
|
form: ObjRef,
|
|
) -> Result<PdfDict, String> {
|
|
let resolved = parser.resolve(resources).map_err(|error| error.to_string())?;
|
|
let mut resources = resolved.as_dict().cloned().unwrap_or_default();
|
|
let mut xobjects = match resources.get("XObject") {
|
|
Some(value) => parser
|
|
.resolve(value)
|
|
.map_err(|error| error.to_string())?
|
|
.as_dict()
|
|
.cloned()
|
|
.unwrap_or_default(),
|
|
None => PdfDict::new(),
|
|
};
|
|
xobjects.map.insert(name.to_string(), PdfObj::Ref(form));
|
|
resources
|
|
.map
|
|
.insert("XObject".to_string(), PdfObj::Dict(xobjects));
|
|
Ok(resources)
|
|
}
|
|
|
|
fn stream_body(extra_dict: &str, data: &[u8]) -> Vec<u8> {
|
|
let mut output = format!("<< /Length {} {} >>\nstream\n", data.len(), extra_dict).into_bytes();
|
|
output.extend_from_slice(data);
|
|
if !output.ends_with(b"\n") {
|
|
output.push(b'\n');
|
|
}
|
|
output.extend_from_slice(b"endstream");
|
|
output
|
|
}
|
|
|
|
fn object_body(object: &PdfObj) -> Vec<u8> {
|
|
let mut output = Vec::new();
|
|
write_object(&mut output, object);
|
|
output
|
|
}
|
|
|
|
fn object_inline(object: &PdfObj) -> String {
|
|
String::from_utf8_lossy(&object_body(object)).into_owned()
|
|
}
|
|
|
|
fn write_object(output: &mut Vec<u8>, object: &PdfObj) {
|
|
match object {
|
|
PdfObj::Null => output.extend_from_slice(b"null"),
|
|
PdfObj::Bool(value) => output.extend_from_slice(if *value { b"true" } else { b"false" }),
|
|
PdfObj::Int(value) => output.extend_from_slice(value.to_string().as_bytes()),
|
|
PdfObj::Real(value) => output.extend_from_slice(number(*value).as_bytes()),
|
|
PdfObj::Name(value) => {
|
|
output.push(b'/');
|
|
write_name(output, value);
|
|
}
|
|
PdfObj::Str(value) => {
|
|
output.push(b'<');
|
|
for byte in value {
|
|
output.extend_from_slice(format!("{byte:02X}").as_bytes());
|
|
}
|
|
output.push(b'>');
|
|
}
|
|
PdfObj::Array(values) => {
|
|
output.push(b'[');
|
|
for value in values {
|
|
write_object(output, value);
|
|
output.push(b' ');
|
|
}
|
|
output.push(b']');
|
|
}
|
|
PdfObj::Dict(dict) => {
|
|
output.extend_from_slice(b"<<");
|
|
let mut entries: Vec<_> = dict.map.iter().collect();
|
|
entries.sort_by(|left, right| left.0.cmp(right.0));
|
|
for (key, value) in entries {
|
|
output.extend_from_slice(b" /");
|
|
write_name(output, key);
|
|
output.push(b' ');
|
|
write_object(output, value);
|
|
}
|
|
output.extend_from_slice(b" >>");
|
|
}
|
|
PdfObj::Ref(reference) => output.extend_from_slice(
|
|
format!("{} {} R", reference.num, reference.gen).as_bytes(),
|
|
),
|
|
PdfObj::Stream(stream) => {
|
|
let mut dict = stream.dict.clone();
|
|
dict.map
|
|
.insert("Length".to_string(), PdfObj::Int(stream.data.len() as i64));
|
|
write_object(output, &PdfObj::Dict(dict));
|
|
output.extend_from_slice(b"\nstream\n");
|
|
output.extend_from_slice(&stream.data);
|
|
output.extend_from_slice(b"\nendstream");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn write_name(output: &mut Vec<u8>, value: &str) {
|
|
for byte in value.bytes() {
|
|
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') {
|
|
output.push(byte);
|
|
} else {
|
|
output.extend_from_slice(format!("#{byte:02X}").as_bytes());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn number(value: f64) -> String {
|
|
if value.fract().abs() < 1e-9 {
|
|
format!("{value:.0}")
|
|
} else {
|
|
let mut value = format!("{value:.6}");
|
|
while value.ends_with('0') {
|
|
value.pop();
|
|
}
|
|
value
|
|
}
|
|
}
|
|
|
|
fn write_rect_path(output: &mut Vec<u8>, rect: Rect) {
|
|
output.extend_from_slice(
|
|
format!(
|
|
"{} {} {} {} re\n",
|
|
number(rect.min_x),
|
|
number(rect.min_y),
|
|
number(rect.width()),
|
|
number(rect.height())
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
}
|
|
|
|
fn write_paint_commands(output: &mut Vec<u8>, commands: &[PaintCommand]) {
|
|
for command in commands {
|
|
match command {
|
|
PaintCommand::StaffLine {
|
|
start,
|
|
end,
|
|
thickness,
|
|
}
|
|
| PaintCommand::Stem {
|
|
start,
|
|
end,
|
|
thickness,
|
|
} => {
|
|
output.extend_from_slice(
|
|
format!(
|
|
"q 0 G {} w {} {} m {} {} l S Q\n",
|
|
number(*thickness),
|
|
number(start.x),
|
|
number(start.y),
|
|
number(end.x),
|
|
number(end.y)
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
}
|
|
PaintCommand::Notehead {
|
|
center,
|
|
width,
|
|
height,
|
|
filled,
|
|
} => write_ellipse(output, *center, *width, *height, *filled),
|
|
PaintCommand::Beam { corners } => {
|
|
output.extend_from_slice(b"q 0 g ");
|
|
write_polygon(output, corners);
|
|
output.extend_from_slice(b" f Q\n");
|
|
}
|
|
PaintCommand::Dot { center, diameter } => {
|
|
write_ellipse(output, *center, *diameter, *diameter, true);
|
|
}
|
|
PaintCommand::AccidentalText { origin, name } => {
|
|
write_accidental_geometry(output, *origin, name);
|
|
}
|
|
PaintCommand::Path {
|
|
commands,
|
|
paint,
|
|
line_width,
|
|
} => {
|
|
output.extend_from_slice(format!("q 0 g 0 G {} w\n", number(*line_width)).as_bytes());
|
|
write_path_commands(output, commands);
|
|
output.extend_from_slice(match paint {
|
|
PathPaint::Stroke => b"S Q\n",
|
|
PathPaint::Fill | PathPaint::FillEvenOdd => b"f Q\n",
|
|
PathPaint::FillStroke | PathPaint::FillStrokeEvenOdd => b"B Q\n",
|
|
PathPaint::None => b"n Q\n",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn write_path_commands(output: &mut Vec<u8>, commands: &[PathCommand]) {
|
|
for command in commands {
|
|
match command {
|
|
PathCommand::Move(point) => output.extend_from_slice(
|
|
format!("{} {} m\n", number(point.x), number(point.y)).as_bytes(),
|
|
),
|
|
PathCommand::Line(point) => output.extend_from_slice(
|
|
format!("{} {} l\n", number(point.x), number(point.y)).as_bytes(),
|
|
),
|
|
PathCommand::Cubic(a, b, c) => output.extend_from_slice(
|
|
format!(
|
|
"{} {} {} {} {} {} c\n",
|
|
number(a.x),
|
|
number(a.y),
|
|
number(b.x),
|
|
number(b.y),
|
|
number(c.x),
|
|
number(c.y)
|
|
)
|
|
.as_bytes(),
|
|
),
|
|
PathCommand::Close => output.extend_from_slice(b"h\n"),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn write_ellipse(output: &mut Vec<u8>, center: Point, width: f64, height: f64, filled: bool) {
|
|
const KAPPA: f64 = 0.552_284_749_830_793_6;
|
|
let rx = width * 0.5;
|
|
let ry = height * 0.5;
|
|
output.extend_from_slice(b"q 0 g 0 G ");
|
|
output.extend_from_slice(
|
|
format!("{} {} m\n", number(center.x + rx), number(center.y)).as_bytes(),
|
|
);
|
|
for (a, b, c) in [
|
|
(
|
|
Point::new(center.x + rx, center.y + ry * KAPPA),
|
|
Point::new(center.x + rx * KAPPA, center.y + ry),
|
|
Point::new(center.x, center.y + ry),
|
|
),
|
|
(
|
|
Point::new(center.x - rx * KAPPA, center.y + ry),
|
|
Point::new(center.x - rx, center.y + ry * KAPPA),
|
|
Point::new(center.x - rx, center.y),
|
|
),
|
|
(
|
|
Point::new(center.x - rx, center.y - ry * KAPPA),
|
|
Point::new(center.x - rx * KAPPA, center.y - ry),
|
|
Point::new(center.x, center.y - ry),
|
|
),
|
|
(
|
|
Point::new(center.x + rx * KAPPA, center.y - ry),
|
|
Point::new(center.x + rx, center.y - ry * KAPPA),
|
|
Point::new(center.x + rx, center.y),
|
|
),
|
|
] {
|
|
output.extend_from_slice(
|
|
format!(
|
|
"{} {} {} {} {} {} c\n",
|
|
number(a.x),
|
|
number(a.y),
|
|
number(b.x),
|
|
number(b.y),
|
|
number(c.x),
|
|
number(c.y)
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
}
|
|
output.extend_from_slice(if filled { b"f Q\n" } else { b"1 g B Q\n" });
|
|
}
|
|
|
|
fn write_polygon(output: &mut Vec<u8>, points: &[Point; 4]) {
|
|
output.extend_from_slice(
|
|
format!("{} {} m", number(points[0].x), number(points[0].y)).as_bytes(),
|
|
);
|
|
for point in &points[1..] {
|
|
output.extend_from_slice(format!(" {} {} l", number(point.x), number(point.y)).as_bytes());
|
|
}
|
|
output.extend_from_slice(b" h");
|
|
}
|
|
|
|
fn write_accidental_geometry(output: &mut Vec<u8>, origin: Point, name: &str) {
|
|
let size = 4.0;
|
|
output.extend_from_slice(b"q 0 G 0.6 w\n");
|
|
if name.contains("Sharp") {
|
|
for x in [origin.x - size * 0.25, origin.x + size * 0.25] {
|
|
output.extend_from_slice(
|
|
format!("{} {} m {} {} l S\n", number(x), number(origin.y - size), number(x), number(origin.y + size)).as_bytes(),
|
|
);
|
|
}
|
|
for y in [origin.y - size * 0.3, origin.y + size * 0.3] {
|
|
output.extend_from_slice(
|
|
format!("{} {} m {} {} l S\n", number(origin.x - size * 0.7), number(y), number(origin.x + size * 0.7), number(y + size * 0.2)).as_bytes(),
|
|
);
|
|
}
|
|
} else {
|
|
output.extend_from_slice(
|
|
format!("{} {} m {} {} l S\n", number(origin.x), number(origin.y - size), number(origin.x), number(origin.y + size)).as_bytes(),
|
|
);
|
|
}
|
|
output.extend_from_slice(b"Q\n");
|
|
}
|
|
|
|
fn merge_rectangles(mut values: Vec<Rect>) -> Vec<Rect> {
|
|
values.sort_by(|left, right| left.min_x.total_cmp(&right.min_x));
|
|
let mut output: Vec<Rect> = Vec::new();
|
|
for value in values {
|
|
if let Some(existing) = output
|
|
.iter_mut()
|
|
.find(|existing| existing.expand(0.5).intersects(value))
|
|
{
|
|
*existing = existing.union(value);
|
|
} else {
|
|
output.push(value);
|
|
}
|
|
}
|
|
output
|
|
}
|
|
|
|
fn last_startxref(bytes: &[u8]) -> Option<usize> {
|
|
let marker = b"startxref";
|
|
let position = bytes.windows(marker.len()).rposition(|window| window == marker)?;
|
|
let mut cursor = position + marker.len();
|
|
while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
|
|
cursor += 1;
|
|
}
|
|
let start = cursor;
|
|
while bytes.get(cursor).is_some_and(u8::is_ascii_digit) {
|
|
cursor += 1;
|
|
}
|
|
std::str::from_utf8(&bytes[start..cursor]).ok()?.parse().ok()
|
|
}
|
|
|
|
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
|
|
haystack.windows(needle.len()).any(|window| window == needle)
|
|
}
|
|
|
|
fn xmp_summary(original_hash: &[u8; 32], edit_count: usize) -> String {
|
|
format!(
|
|
"<?xpacket begin='\u{feff}'?>\n<x:xmpmeta xmlns:x='adobe:ns:meta/'><rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'><rdf:Description xmlns:mp='https://makepad.dev/ns/score-pdf/1.0/' mp:OriginalSHA256='{}' mp:EditCount='{}'/></rdf:RDF></x:xmpmeta>\n<?xpacket end='w'?>",
|
|
crate::sha256::hex(original_hash), edit_count
|
|
)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn _color_is_retained(color: Color) -> Color {
|
|
color
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::display::{ContentStreamRecord, DisplayList, PageIndex, SourceSpan};
|
|
use std::collections::HashMap;
|
|
|
|
#[test]
|
|
fn startxref_uses_last_revision() {
|
|
assert_eq!(last_startxref(b"startxref\n12\n%%EOF\nstartxref\n98\n%%EOF"), Some(98));
|
|
}
|
|
|
|
#[test]
|
|
fn overlapping_clips_are_merged() {
|
|
let merged = merge_rectangles(vec![
|
|
Rect::new(0.0, 0.0, 10.0, 10.0),
|
|
Rect::new(9.0, 5.0, 20.0, 12.0),
|
|
]);
|
|
assert_eq!(merged, vec![Rect::new(0.0, 0.0, 20.0, 12.0)]);
|
|
}
|
|
|
|
#[test]
|
|
fn exact_rewrite_changes_only_the_provenance_range() {
|
|
let object = ObjRef { num: 7, gen: 0 };
|
|
let display = DisplayList {
|
|
page: PageIndex(0),
|
|
page_object: ObjRef { num: 3, gen: 0 },
|
|
media_box: Rect::new(0.0, 0.0, 100.0, 100.0),
|
|
crop_box: Rect::new(0.0, 0.0, 100.0, 100.0),
|
|
rotation: 0,
|
|
content_streams: vec![ContentStreamRecord {
|
|
object,
|
|
stream_index: 0,
|
|
decoded: b"q\nS\nQ".to_vec(),
|
|
}],
|
|
operators: Vec::new(),
|
|
primitives: Vec::new(),
|
|
fonts: HashMap::new(),
|
|
};
|
|
let edit = OperatorEdit {
|
|
source: SourceSpan {
|
|
object,
|
|
stream_index: 0,
|
|
decoded_bytes: 2..3,
|
|
operator_index: 1,
|
|
subpath_index: Some(0),
|
|
form_chain: Vec::new(),
|
|
},
|
|
replacement: b"n".to_vec(),
|
|
};
|
|
assert_eq!(
|
|
rewrite_content_streams(&display, &[&edit]).unwrap(),
|
|
b"q\nn\nQ\n"
|
|
);
|
|
}
|
|
}
|