mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
Merge upstream main 1952a8880 into next
ROADMAP.md, docs/roadmap.md: union of both sides' lines, each once. parity/acrobat-features.toml: the XFA entry has both sides' evidence and notes (#2604 page areas, #2813 CLI layout).
This commit is contained in:
@@ -298,14 +298,21 @@ fn info(args: &[String]) -> Result<(), CliError> {
|
||||
let path = *positional(args).first().ok_or("info: missing file")?;
|
||||
let bytes = read(path)?;
|
||||
let password = flag(args, "--password");
|
||||
let info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?;
|
||||
let mut info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?;
|
||||
let pdf_pages = info.pages.len();
|
||||
let acroform_fields = info.fields.len();
|
||||
// Dynamic XFA forms: say what laying the template out gives (what the app shows).
|
||||
let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic))
|
||||
.then(|| {
|
||||
let mut s = pdfcraft_engine::Session::new();
|
||||
let id = s.open("info.pdf", None, bytes.clone(), password).ok()?;
|
||||
let d = s.get(id)?;
|
||||
d.xfa.as_ref().map(|x| serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }))
|
||||
d.xfa.as_ref().map(|x| {
|
||||
info = d.info.clone();
|
||||
// The in-memory layout is an appended revision, not the file being inspected.
|
||||
info.file_size = bytes.len();
|
||||
serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let json = serde_json::json!({
|
||||
@@ -313,6 +320,7 @@ fn info(args: &[String]) -> Result<(), CliError> {
|
||||
"pdf_version": info.pdf_version,
|
||||
"file_size": info.file_size,
|
||||
"pages": info.pages.len(),
|
||||
"pdf_pages": pdf_pages, "acroform_fields": acroform_fields,
|
||||
"first_page_pt": info.pages.first().map(|p| [p.width, p.height]),
|
||||
"title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator,
|
||||
"encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript,
|
||||
@@ -348,7 +356,19 @@ fn text(args: &[String]) -> Result<(), CliError> {
|
||||
}
|
||||
}
|
||||
let password = flag(args, "--password");
|
||||
let bytes = read(path)?;
|
||||
let mut bytes = read(path)?;
|
||||
// Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
|
||||
// permissions password, a document that doesn't allow copying gives no text.
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) {
|
||||
// The engine's hint is about editing in the app; on the command line the way in is
|
||||
// `--password`.
|
||||
doc.check_copying()
|
||||
.map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?;
|
||||
if doc.xfa.is_some() {
|
||||
bytes = doc.display.clone();
|
||||
}
|
||||
}
|
||||
let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() });
|
||||
// A document the renderer could not open (wrong or missing password, unparseable file) has
|
||||
// no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask
|
||||
@@ -359,15 +379,6 @@ fn text(args: &[String]) -> Result<(), CliError> {
|
||||
return Err("text: the document could not be parsed".into());
|
||||
}
|
||||
}
|
||||
// Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
|
||||
// permissions password, a document that doesn't allow copying gives no text.
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
if let Some(doc) = session.open(path, None, bytes, password).ok().and_then(|id| session.get(id)) {
|
||||
// The engine's hint is about editing in the app; on the command line the way in is
|
||||
// `--password`.
|
||||
doc.check_copying()
|
||||
.map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?;
|
||||
}
|
||||
let pages: Vec<usize> = match selected_page {
|
||||
Some(page) => vec![page],
|
||||
None => (0..r.page_count()).collect(),
|
||||
@@ -588,10 +599,15 @@ fn render(args: &[String]) -> Result<(), CliError> {
|
||||
// The app renders a display copy that draws comments missing their /AP streams; the CLI
|
||||
// must agree with it (and with independent viewers), while keeping the file it reads intact.
|
||||
let bytes = read(path)?;
|
||||
let mut r = PageRenderer::new(
|
||||
pdfcraft_engine::display_bytes_for(bytes.clone(), flag(args, "--password")),
|
||||
RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() },
|
||||
);
|
||||
let password = flag(args, "--password");
|
||||
let display = if inspect(bytes.clone(), password).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?;
|
||||
session.get(id).ok_or("render: the document could not be opened")?.display.clone()
|
||||
} else {
|
||||
pdfcraft_engine::display_bytes_for(bytes, password)
|
||||
};
|
||||
let mut r = PageRenderer::new(display, RenderConfig { password: password.map(Arc::from), reject_oversize: true, ..Default::default() });
|
||||
let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
|
||||
if let Some(e) = p.error {
|
||||
return Err(e.into());
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! CLI page commands must use the same dynamic XFA layout as the app and tools (#1645).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::Arc;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
fn fixture(test: &str) -> PathBuf {
|
||||
let xdp = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="page"><occur max="-1"/>
|
||||
<contentArea x="20pt" y="20pt" w="160pt" h="260pt"/>
|
||||
<medium short="200pt" long="300pt"/>
|
||||
</pageArea></pageSet>
|
||||
<draw w="160pt" h="20pt"><value><text>First XFA page</text></value></draw>
|
||||
<field name="name" w="160pt" h="20pt"><ui><textEdit/></ui><value><text>Ada</text></value></field>
|
||||
<subform name="last" layout="tb"><breakBefore targetType="pageArea"/>
|
||||
<draw w="160pt" h="20pt"><value><text>Second XFA page</text></value></draw>
|
||||
</subform>
|
||||
</subform></template></xdp:xdp>"#;
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".into(),
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(),
|
||||
format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n");
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-xfa-{test}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("form.pdf");
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn success(output: &Output) {
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_reports_the_laid_out_pages_and_fields() {
|
||||
let path = fixture("info");
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
|
||||
success(&output);
|
||||
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(info["xfa_layout"]["pages"], 2, "the synthetic form lays out into two pages");
|
||||
assert_eq!(info["pages"], 2, "top-level page count must agree with the app");
|
||||
assert_eq!(info["fields"], 1);
|
||||
assert_eq!(info["pdf_pages"], 1);
|
||||
assert_eq!(info["acroform_fields"], 0);
|
||||
assert_eq!(info["first_page_pt"], serde_json::json!([200.0, 300.0]));
|
||||
assert_eq!(info["file_size"], original.len());
|
||||
assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() {
|
||||
let path = fixture("text");
|
||||
let output = Command::new(BIN).arg("text").arg(&path).output().unwrap();
|
||||
success(&output);
|
||||
let text = String::from_utf8(output.stdout).unwrap();
|
||||
assert!(text.contains("First XFA page") && text.contains("Second XFA page"), "{text:?}");
|
||||
let output = Command::new(BIN).arg("text").arg(&path).args(["--page", "2"]).output().unwrap();
|
||||
success(&output);
|
||||
let text = String::from_utf8(output.stdout).unwrap();
|
||||
assert!(text.contains("Second XFA page") && !text.contains("First XFA page"), "{text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_matches_the_engine_layout_on_the_second_page() {
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
let path = fixture("render");
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
let id = session.open("form.pdf", None, Arc::new(original.clone()), None).unwrap();
|
||||
let doc = session.get(id).unwrap();
|
||||
assert_eq!(doc.info.pages.len(), 2);
|
||||
assert!(doc.text_lines(1).iter().any(|line| line.text.contains("Second XFA page")));
|
||||
let mut renderer = PageRenderer::new(doc.display.clone(), RenderConfig::default());
|
||||
let expected = renderer.render(RenderRequest { page: 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
assert!(expected.error.is_none(), "{:?}", expected.error);
|
||||
assert!(expected.rgba.as_chunks::<4>().0.iter().filter(|[r, _, _, a]| *r < 128 && *a > 0).count() > 10, "the layout has visible ink");
|
||||
let out = path.with_extension("pam");
|
||||
let output = Command::new(BIN).arg("render").arg(&path).args(["--page", "2", "--dpi", "72", "--out"]).arg(&out).output().unwrap();
|
||||
success(&output);
|
||||
let actual = std::fs::read(out).unwrap();
|
||||
let header = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", expected.width, expected.height);
|
||||
assert!(actual.starts_with(header.as_bytes()));
|
||||
assert_eq!(&actual[header.len()..], &expected.rgba[..]);
|
||||
assert_eq!(std::fs::read(path).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_laid_out_form_keeps_the_original_copy_permissions() {
|
||||
let path = fixture("permissions");
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(std::fs::read(&path).unwrap())).unwrap();
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
user_password: "openme",
|
||||
owner_password: "boss",
|
||||
permissions: -17,
|
||||
encrypt_metadata: true,
|
||||
seed: [17; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let original = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap();
|
||||
std::fs::write(&path, &original).unwrap();
|
||||
let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "openme"]).output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("don't allow copying"));
|
||||
let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "boss", "--page", "2"]).output().unwrap();
|
||||
success(&output);
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("Second XFA page"));
|
||||
let output = Command::new(BIN).arg("info").arg(&path).args(["--password", "openme"]).output().unwrap();
|
||||
success(&output);
|
||||
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(info["pages"], 2);
|
||||
assert_eq!(info["encrypted"], true);
|
||||
assert_eq!(info["file_size"], original.len());
|
||||
assert_eq!(std::fs::read(path).unwrap(), original);
|
||||
}
|
||||
@@ -566,7 +566,7 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
|
||||
setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
// egui-wgpu requests only 8192 pixels even when the adapter can render larger surfaces.
|
||||
// A restored 3440-point window at 250% DPI requests 8600 pixels and otherwise panics in
|
||||
// Surface::configure before the app starts. Keep the renderer's other device requirements,
|
||||
// Surface::configure before the app starts. Keep the renderer's other compatible requirements,
|
||||
// but enable the adapter's actual 2D texture extent (also for smaller/downlevel adapters).
|
||||
let device_descriptor = std::sync::Arc::clone(&setup.device_descriptor);
|
||||
setup.device_descriptor = std::sync::Arc::new(move |adapter| {
|
||||
@@ -607,8 +607,9 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
|
||||
}
|
||||
|
||||
/// Surface textures must fit the device's enabled limits, not just the physical GPU's limits.
|
||||
/// Changing only the 2D extent preserves egui-wgpu's backend-specific downlevel requirements.
|
||||
/// Clamp egui-wgpu's backend-specific requirements before enabling the adapter's full 2D extent.
|
||||
fn surface_texture_limits(mut required: eframe::wgpu::Limits, supported: &eframe::wgpu::Limits) -> eframe::wgpu::Limits {
|
||||
required = required.or_worse_values_from(supported);
|
||||
required.max_texture_dimension_2d = supported.max_texture_dimension_2d;
|
||||
required
|
||||
}
|
||||
@@ -913,16 +914,51 @@ mod tests {
|
||||
fn surface_limits_preserve_other_requirements_and_never_overrequest() {
|
||||
for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] {
|
||||
for extent in [4096, 8192, 16384] {
|
||||
let supported = eframe::wgpu::Limits { max_texture_dimension_2d: extent, ..required.clone() };
|
||||
let supported = eframe::wgpu::Limits {
|
||||
max_texture_dimension_2d: extent,
|
||||
max_bind_groups: 8,
|
||||
min_uniform_buffer_offset_alignment: 128,
|
||||
min_storage_buffer_offset_alignment: 128,
|
||||
..required.clone()
|
||||
};
|
||||
let enabled = super::surface_texture_limits(required.clone(), &supported);
|
||||
let mut expected = required.clone();
|
||||
expected.max_texture_dimension_2d = extent;
|
||||
assert_eq!(enabled, expected);
|
||||
assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d);
|
||||
assert!(enabled.check_limits(&supported));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_limits_fit_v3d_shader_variable_limit() {
|
||||
// Issue #2383: V3D supports 15 variables, below the default request of 16.
|
||||
let required = eframe::wgpu::Limits::default();
|
||||
let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() };
|
||||
let enabled = super::surface_texture_limits(required.clone(), &supported);
|
||||
assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
|
||||
let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required };
|
||||
assert_eq!(enabled, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() {
|
||||
let required = eframe::wgpu::Limits::downlevel_webgl2_defaults();
|
||||
let supported = eframe::wgpu::Limits {
|
||||
max_texture_dimension_2d: 2048,
|
||||
max_bind_groups: 2,
|
||||
max_vertex_attributes: 8,
|
||||
min_uniform_buffer_offset_alignment: 512,
|
||||
min_storage_buffer_offset_alignment: 512,
|
||||
..required.clone()
|
||||
};
|
||||
let enabled = super::surface_texture_limits(required, &supported);
|
||||
assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
|
||||
// Larger minimum alignments are weaker capabilities, unlike maximum limits.
|
||||
assert_eq!(enabled, supported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() {
|
||||
use eframe::egui_wgpu::winit::surface_fit;
|
||||
|
||||
Reference in New Issue
Block a user