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:
aiongg
2026-10-18 14:22:30 +00:00
44 changed files with 702 additions and 98 deletions
+32 -16
View File
@@ -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());
+136
View File
@@ -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);
}
+39 -3
View File
@@ -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;