Merge main into feat/native-save-precision

This commit is contained in:
dotCooCoo
2026-10-29 08:45:29 -07:00
19 changed files with 441 additions and 63 deletions
+3
View File
@@ -44,6 +44,9 @@ clipboard-win = { workspace = true }
# GPU that shows it: drawing on a GPU without a monitor reset the display driver. Safe wrappers over
# Win32 (MIT); the `user` feature is User32.dll only. Only the wgpu renderer chooses an adapter.
winsafe = { version = "0.0.29", default-features = false, features = ["user"], optional = true }
# Waits for Direct3D 12's adapter list before the window starts (src/gpu.rs, #4150). MIT OR
# Apache-2.0, already in the build through eframe.
pollster = "1"
# DirectWrite's system font collection and GDI's font list (src/system_fonts.rs): fonts that font
# services such as Adobe Fonts load in place (#579, #1258). MIT OR Apache-2.0, already in the build
+39 -7
View File
@@ -126,13 +126,45 @@ pub fn preferred_compositor(pref: Option<&str>, env: Option<PowerPreference>) ->
if automatic(pref, env) { crate::compositor_gpu::compositor_gpu() } else { None }
}
/// The graphics backends the window starts with: on Windows Direct3D 12, falling back to OpenGL,
/// unless `WGPU_BACKEND` names others (`env_set`; `default` then holds them). Creating a Vulkan
/// instance loads every installed Vulkan driver into the process, and a faulty one (Intel's
/// `igvk64.dll`, #806) crashed the app before its window appeared. DX12 is Windows' own backend,
/// as in PdfCraft. Elsewhere, `default`.
pub fn backends(default: Backends, env_set: bool) -> Backends {
if cfg!(windows) && !env_set { Backends::DX12 | Backends::GL } else { default }
/// The graphics backends the window starts with: on Windows Direct3D 12, with OpenGL beside it
/// only when `opengl` ([`windows_opengl`]), unless `WGPU_BACKEND` names others (`env_set`;
/// `default` then holds them). Creating a Vulkan instance loads every installed Vulkan driver into
/// the process, and a faulty one (Intel's `igvk64.dll`, #806) crashed the app before its window
/// appeared. DX12 is Windows' own backend, as in PdfCraft. Elsewhere, `default`.
pub fn backends(default: Backends, env_set: bool, opengl: bool) -> Backends {
match (cfg!(windows) && !env_set, opengl) {
(true, true) => Backends::DX12 | Backends::GL,
(true, false) => Backends::DX12,
(false, _) => default,
}
}
/// Whether a Windows start lists OpenGL's adapters beside Direct3D 12's: only when it leaves an
/// adapter out (an earlier start crashed on it, or a restart failed on it) or DX12 lists none
/// (Windows 7). wgpu's OpenGL backend keeps a hidden window on a thread that pumps no messages,
/// and Windows waits on it when the input language changes, so the whole app hung on a switch to
/// another keyboard layout (#4150, gfx-rs/wgpu#10504), even drawing with DX12.
pub fn windows_opengl(left_out: &LeftOut) -> bool {
left_out.opengl_only || !left_out.adapters.is_empty() || !skipped().is_empty() || !dx12_lists_adapters()
}
/// Whether Direct3D 12 lists any adapter, WARP's software one included. A driver that crashes here
/// crashes a start while it lists the adapters, which the next start answers with OpenGL alone.
#[cfg(windows)]
fn dx12_lists_adapters() -> bool {
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
descriptor.backends = Backends::DX12;
// FXC is part of Windows, as for the window's own instance (#1194).
descriptor.backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::Fxc;
let instance = wgpu::Instance::new(descriptor);
let found = pollster::block_on(instance.enumerate_adapters(Backends::DX12)).len();
log::info!("Direct3D 12 lists {found} graphics adapters");
found > 0
}
#[cfg(not(windows))]
fn dx12_lists_adapters() -> bool {
false
}
/// The largest texture side to ask the device for, which bounds the window's surface: the
+16 -4
View File
@@ -451,13 +451,25 @@ fn a_windows_gpu_renders_through_dx12_before_vulkan() {
#[test]
fn windows_never_loads_vulkan_drivers_unless_asked() {
let all = Backends::all();
let start = backends(all, false);
let start = backends(all, false, false);
let fallback = backends(all, false, true);
if cfg!(windows) {
assert_eq!(start, Backends::DX12 | Backends::GL, "#806");
assert_eq!(start, Backends::DX12, "#806, and no OpenGL unless needed (#4150)");
assert_eq!(fallback, Backends::DX12 | Backends::GL);
} else {
assert_eq!(start, all);
assert_eq!((start, fallback), (all, all));
}
assert_eq!(backends(Backends::VULKAN, true), Backends::VULKAN, "WGPU_BACKEND=vulkan still picks it");
assert_eq!(backends(Backends::VULKAN, true, false), Backends::VULKAN, "WGPU_BACKEND=vulkan still picks it");
assert_eq!(backends(Backends::GL, true, false), Backends::GL, "WGPU_BACKEND=gl still picks it");
}
#[test]
fn windows_lists_opengl_once_a_start_leaves_an_adapter_out() {
let crashed = LeftOut { adapters: vec!["Dx12:8086:3ea0".into()], ..LeftOut::default() };
assert!(windows_opengl(&crashed), "#2243: the next start can fall back to OpenGL");
let listing = LeftOut { opengl_only: true, ..LeftOut::default() };
assert!(windows_opengl(&listing));
assert_eq!(listing.backends(backends(Backends::all(), false, true)), Backends::GL);
}
#[test]
+3 -2
View File
@@ -313,8 +313,9 @@ fn main() -> std::process::ExitCode {
// Metal on an Iris Pro, #651). `WGPU_VALIDATION_INDIRECT_CALL=1` turns it back on.
create.instance_descriptor.flags =
(eframe::wgpu::InstanceFlags::from_build_config() - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
create.instance_descriptor.backends =
left_out.backends(gpu::backends(create.instance_descriptor.backends, std::env::var_os("WGPU_BACKEND").is_some()));
let env_set = std::env::var_os("WGPU_BACKEND").is_some();
let opengl = cfg!(windows) && !env_set && gpu::windows_opengl(&left_out);
create.instance_descriptor.backends = left_out.backends(gpu::backends(create.instance_descriptor.backends, env_set, opengl));
// DX12 shaders compile with FXC, part of Windows: wgpu's default loads whichever
// dxcompiler.dll the search path finds, and one without its dxil.dll fails every shader,
// losing the device before the first frame (#1194). `WGPU_DX12_COMPILER` picks another.
+111 -14
View File
@@ -872,9 +872,15 @@ proptest! {
read = Some(r.as_ref().map(|d| d.layers.iter().any(|l| l.children().is_some_and(|c| !c.is_empty()))).map_err(ToString::to_string));
r.ok()
})?;
// A function written in the page's resources goes by the page's object (#4366).
let why = if function == "spaces past the cap" {
"a stream in it (object 3) would decode to more than 256 MB"
} else {
"a stream in it (object 3) has decode parameters that can't be applied"
};
match read.expect("the import ran") {
Ok(art) => prop_assert!(!refused && art, "{what}: opened (its art read: {art})"),
Err(e) => prop_assert!(refused && e.contains("would decode to more than 256 MB"), "{what}: {e}"),
Err(e) => prop_assert!(refused && e.contains(why), "{what}: {e}"),
}
}
}
@@ -3762,24 +3768,52 @@ fn pdf_streams_the_reader_would_fail_on_are_refused() {
};
let spaces = calculator(&spaces_past_the_cap(), "");
let program = deflated(b"{ dup 1 exch sub 0 }");
// The message names the stream, so a report of a file that can't be shared says which (#4366).
let (parameters, cap) = ("has decode parameters that can't be applied", "would decode to more than 256 MB");
let files = [
("a form with Columns 0", form(zero, "0 0 5 5 re f")),
("a form with a huge Columns", form(huge, "0 0 5 5 re f")),
("an inline image with Columns 0", deep_pdf(&inline("<< /Predictor 2 /Columns 0 >>"), "<< >>")),
("an inline image with a huge Columns", deep_pdf(&inline("<< /Predictor 15 /Columns 99999999999 >>"), "<< >>")),
("an inline image in a compressed form", form("", &inline("<< /Predictor 2 /Columns 0 >>"))),
("an object stream with Columns 0", pdf_with_object_stream("[0 0 100 100]", zero)),
("a form of spaces past the cap", pdf_with("/F Do", "<< /XObject << /F 5 0 R >> >>", &[packed_as_is(FORM, &spaces_past_the_cap())])),
("a function of spaces past the cap in an array", held(format!("[{spaces}]"))),
("a function of spaces past the cap in a dictionary", held(format!("<< /K {spaces} >>"))),
("a function of spaces past the cap in a shading", shaded(&spaces)),
("a function with Columns 0 in an array", held(format!("[{}]", calculator(&program, "/DecodeParms [null << /Predictor 2 /Columns 0 >>]")))),
("a form with Columns 0", form(zero, "0 0 5 5 re f"), format!("a form in it (object 5) {parameters}")),
("a form with a huge Columns", form(huge, "0 0 5 5 re f"), format!("a form in it (object 5) {parameters}")),
(
"an inline image with Columns 0",
deep_pdf(&inline("<< /Predictor 2 /Columns 0 >>"), "<< >>"),
format!("an inline image in the content of object 4 {parameters}"),
),
(
"an inline image with a huge Columns",
deep_pdf(&inline("<< /Predictor 15 /Columns 99999999999 >>"), "<< >>"),
format!("an inline image in the content of object 4 {parameters}"),
),
(
"an inline image in a compressed form",
form("", &inline("<< /Predictor 2 /Columns 0 >>")),
format!("an inline image in the content of object 5 {parameters}"),
),
// Read as the file opens, before its objects can be looked at one by one.
(
"an object stream with Columns 0",
pdf_with_object_stream("[0 0 100 100]", zero),
format!("a stream in it {cap}, or its decode parameters can't be applied"),
),
(
"a form of spaces past the cap",
pdf_with("/F Do", "<< /XObject << /F 5 0 R >> >>", &[packed_as_is(FORM, &spaces_past_the_cap())]),
format!("a form in it (object 5) {cap}"),
),
// A function written inside object 5 goes by it.
("a function of spaces past the cap in an array", held(format!("[{spaces}]")), format!("a stream in it (object 5) {cap}")),
("a function of spaces past the cap in a dictionary", held(format!("<< /K {spaces} >>")), format!("a stream in it (object 5) {cap}")),
("a function of spaces past the cap in a shading", shaded(&spaces), format!("a stream in it (object 5) {cap}")),
(
"a function with Columns 0 in an array",
held(format!("[{}]", calculator(&program, "/DecodeParms [null << /Predictor 2 /Columns 0 >>]"))),
format!("a stream in it (object 5) {parameters}"),
),
];
on_a_small_stack(move || {
for (what, bytes) in &files {
for (what, bytes, why) in &files {
for name in ["hostile.pdf", "hostile.ai"] {
let e = vectorcraft_engine::cmd::fileio::load(name, bytes).err().map(|e| e.to_string());
assert!(e.as_deref().is_some_and(|e| e.contains("would decode to more than 256 MB")), "{what} as {name}: {e:?}");
assert!(e.as_deref().is_some_and(|e| e.contains(why.as_str())), "{what} as {name}: {e:?}");
}
}
});
@@ -3791,6 +3825,69 @@ fn pdf_streams_the_reader_would_fail_on_are_refused() {
}
}
/// A page drawing a thousand times one form that decodes to 64 MiB (of spaces, then a square):
/// each drawing reads the form again, so the import stops at the page's form content instead of
/// decoding 64 GB (#3569, which hung).
#[test]
fn pdf_a_huge_form_drawn_over_and_over_is_refused_in_bounded_time() {
use std::io::Write as _;
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best());
for _ in 0..64 {
e.write_all(&[b' '; 1 << 20]).unwrap();
}
e.write_all(b"0 0 5 5 re f").unwrap();
let form = packed_as_is(FORM, &e.finish().unwrap());
let bytes = pdf_with(&"/F Do ".repeat(1000), "<< /XObject << /F 5 0 R >> >>", &[form]);
let start = std::time::Instant::now();
let e = vectorcraft_engine::cmd::fileio::load("repeated.pdf", &bytes).err().map(|e| e.to_string());
let took = start.elapsed();
assert!(e.as_deref().is_some_and(|e| e.contains("no document was imported")), "{e:?}");
assert!(took < std::time::Duration::from_secs(60), "took {took:?}");
}
// A large file the reader would fail on says which stream (#4366): an image with its size in
// pixels, a page's content, or the editing data an `.ai` keeps in its first page's `PieceInfo`.
#[test]
fn a_stream_past_the_cap_is_named() {
let image = "/Type /XObject /Subtype /Image /Width 10 /Height 10 /ColorSpace /DeviceRGB /BitsPerComponent 8";
let page = |entries: &str| {
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> {entries} >>")
.into_bytes()
};
let plain = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1).into_bytes();
let pdf = |page: Vec<u8>, more: Vec<Vec<u8>>| {
let head = [b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), page];
pdf_of(&head.into_iter().chain(more).collect::<Vec<_>>())
};
let spaces = spaces_past_the_cap();
let editing = "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 6 0 R /NumBlock 1 >> >> >>";
let files = [
(
"an image",
pdf(page(""), vec![plain("q 10 0 0 10 0 0 cm /Im Do Q"), packed_as_is(image, &spaces)]),
"an image of 10 × 10 px in it (object 5) would decode to more than 256 MB",
),
(
"a page's content",
pdf(page(""), vec![packed_as_is("", &spaces), packed(image, "\0\0\0")]),
"a page's content in it (object 4) would decode to more than 256 MB",
),
(
"the editing data",
pdf(page(editing), vec![plain("0 0 5 5 re f"), packed(image, "\0\0\0"), packed_as_is("", &spaces)]),
"its editing data (object 6) would decode to more than 256 MB",
),
];
on_a_small_stack(move || {
for (what, bytes, why) in &files {
for name in ["large.pdf", "large.ai"] {
let e = vectorcraft_engine::cmd::fileio::load(name, bytes).err().map(|e| e.to_string());
assert!(e.as_deref().is_some_and(|e| e.contains(why)), "{what} as {name}: {e:?}");
}
}
});
}
#[test]
fn pdf_art_that_draws_itself() {
let own = "<< /XObject << /F 5 0 R >> /Pattern << /P 5 0 R >> /Font << /F1 5 0 R >> /ExtGState << /G << /SMask << /S /Alpha /G 5 0 R >> >> >> >>";
+1 -1
View File
@@ -43,5 +43,5 @@ pub use entry::{
import, import_with_report, is_glyph_outlines, is_outlines_note,
};
#[cfg(test)]
pub(crate) use entry::{import_with_reader_options, read};
pub(crate) use entry::{TEST_MAX_OBJECTS, import_with_reader_options, read};
pub(crate) use state::{closed_subpaths, round3};
+16 -1
View File
@@ -1,5 +1,8 @@
use super::cid::{CidText, FontInfo, comparable, font_chars, identify_glyph, matching_face, replacement, unify_radical};
use super::entry::{MAX_NESTED, OVERPRINT_UNREAD, OVERPRINT_ZERO_MODE, PATTERN_REUSE, TEXT_OUTLINES, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE};
use super::entry::{
MAX_NESTED, OVERPRINT_UNREAD, OVERPRINT_ZERO_MODE, PATTERN_REUSE, TEXT_OUTLINES, TOO_MANY_OBJECTS, UNMATCHED_GLYPHS_NOTE,
UNNAMED_CHARACTERS_NOTE, max_objects,
};
use super::state::{Builder, Frame, FrameKind, Saved, bounds, grouped, line_width, mean_scale, paints, round3, stroke_layer};
use super::{
Affine, Appearance, AppearanceItem, Arc, BezPath, BfString, BlendMode, CacheKey, Color, Colors, Families, FillLayer, FillRule, FunctionBudget,
@@ -41,6 +44,7 @@ impl<'p> Builder<'p> {
pattern_keys: HashMap::new(),
function_paints: HashMap::new(),
function_budget: FunctionBudget::new(),
objects_left: max_objects(),
taken: vec![],
scan: Scan::default(),
tag_at: 0,
@@ -66,6 +70,9 @@ impl<'p> Builder<'p> {
/// A deep copy of `node` with fresh ids.
fn reid(&mut self, node: &Node) -> Node {
// Each copy counts against the import's objects: once they are spent nothing more is
// drawn through a mask, so the copying stops too.
self.objects_left = self.objects_left.saturating_sub(1);
let mut n = node.clone();
n.id = self.id();
if let Some(ch) = n.children_mut() {
@@ -191,7 +198,15 @@ impl<'p> Builder<'p> {
}
}
/// Add `n`, drawn now, to the art: left out (with a warning) once the import's objects are
/// spent ([`TOO_MANY_OBJECTS`]).
pub(super) fn push_node(&mut self, mut n: Node) {
let Some(left) = self.objects_left.checked_sub(1) else {
self.lines.remove(&n.id);
self.warn(TOO_MANY_OBJECTS);
return;
};
self.objects_left = left;
if self.blend != BlendMode::Normal && !n.is_container() {
n.blend = self.blend;
}
+29
View File
@@ -25,6 +25,35 @@ pub(super) const MAX_NESTED: u32 = 8;
/// A tiling pattern read this many times with the same art is taken to always draw it.
pub(super) const PATTERN_REUSE: u32 = 16;
/// The most objects one import makes (paths, images, lines of type, and the copies of mask art
/// each masked object takes): half of what a saved document may hold to open again
/// ([`vectorcraft_doc::node::MAX_READ_NODES`]: 4M, 512K in the web build), leaving room for the
/// groups, clips and layers around them. A page of a million paths still comes in whole, as an
/// SVG or CAD drawing of two million objects does. What it stops is a small file drawing one form
/// over and over, which neither the scan's operator limit nor the interpreter's budgets bound
/// (#3569: 1 KB drawing 25 squares 32,000 times took 877 MB, about 1 KB an object, so this is
/// about 2 GB).
const MAX_OBJECTS: usize = vectorcraft_doc::node::MAX_READ_NODES / 2;
/// Warning: the import reached [`MAX_OBJECTS`].
pub(super) const TOO_MANY_OBJECTS: &str = "the file draws more objects than can be imported: the rest were left out";
/// [`MAX_OBJECTS`], lowered by tests that need an import past it.
#[cfg(not(test))]
pub(super) fn max_objects() -> usize {
MAX_OBJECTS
}
#[cfg(test)]
thread_local! {
pub(crate) static TEST_MAX_OBJECTS: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_OBJECTS) };
}
#[cfg(test)]
pub(super) fn max_objects() -> usize {
TEST_MAX_OBJECTS.with(std::cell::Cell::get)
}
/// Stands for the group of art whose group couldn't be told (see [`Builder::unsure`]): it goes
/// to a hidden, non-printing layer of its page.
pub(super) const UNSORTED: usize = usize::MAX;
+2
View File
@@ -122,6 +122,8 @@ pub(super) struct Builder<'p> {
pub(super) function_paints: HashMap<(u128, Option<Vec<String>>), Paint>,
/// What is left of the import's function evaluations for those shadings.
pub(super) function_budget: FunctionBudget,
/// What is left of the import's objects ([`super::entry::max_objects`]).
pub(super) objects_left: usize,
/// Swatch names in use.
pub(super) taken: Vec<String>,
/// The page as walked by [`scan_page`], and how far the interpreter is through it.
+16
View File
@@ -36,6 +36,14 @@ const MAX_STATES: usize = 65_536;
/// takes a few million; the walk reads about 15 million a second, twice as fast as the
/// interpreter draws them.
const MAX_OPS: usize = 64_000_000;
/// The most bytes of form XObject content walked per page, a form drawn again counted again:
/// [`MAX_OPS`] guards content that is mostly operators, this content that isn't (a form of long
/// strings, inline image data or spaces). An operator takes some 10 to 40 bytes (a curve's six
/// numbers about 40), so a page of ordinary content within `MAX_OPS` walks up to about this much;
/// a page that draws a form decoding to 64 MiB a thousand times (#3569, which hung) stops at its
/// 33rd reading. The interpreter reads every form again too, and nothing else bounds what it
/// decodes. (2 GiB: within a 32-bit `usize`, for the web build.)
const MAX_FORM_CONTENT: usize = 2 << 30;
/// The most marked-content sequences noted per page (24 bytes each): past it the rest of the
/// page isn't told apart either.
const MAX_TAGS: usize = 4_000_000;
@@ -461,6 +469,8 @@ struct Walker<'w, 'p> {
ops: usize,
max_ops: usize,
max_form_bytes: usize,
/// The bytes of form content walked so far ([`MAX_FORM_CONTENT`]).
form_content: usize,
out: Scan,
/// A form XObject hidden by its own optional content was met.
hidden_form: bool,
@@ -819,6 +829,11 @@ impl Walker<'_, '_> {
self.out.cut = true;
return;
};
self.form_content = self.form_content.saturating_add(data.len());
if self.form_content > MAX_FORM_CONTENT {
self.out.cut = true;
return;
}
// Ordinary filtered form decoders may return a prefix at their limit.
// Reaching that bound cannot certify a complete form, even at an operator boundary.
if !s.filters().is_empty() && data.len() >= self.max_form_bytes {
@@ -1038,6 +1053,7 @@ fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &
ops: 0,
max_ops: max_ops(),
max_form_bytes,
form_content: 0,
out: Scan::default(),
hidden_form: false,
states: vec![State::default()],
+78 -8
View File
@@ -11,6 +11,7 @@ use kurbo::{Affine, Rect};
use vectorcraft_doc::setup::MAX_BLEED;
use crate::import_color::{direct, items, readable, values};
use crate::streams::{Refusal, Refused, refused};
use crate::{Choice, CropTo, ImportOptions, PdfError};
/// What `info` tells about one page.
@@ -115,7 +116,8 @@ fn procedures_too_deep(o: &Object<'_>) -> bool {
})
}
/// The error of a file with a stream the reader would fail on ([`crate::streams`]).
/// The error of a file with an object or cross-reference stream the reader would fail on as it
/// opens the file ([`crate::streams::unsafe_file_streams`]); [`stream_note`] names any other.
const STREAM_NOTE: &str = "a stream in it would decode to more than 256 MB, or its decode parameters can't be applied";
/// The error of a file with functions or color spaces built on one another past [`MAX_LINKS`]
@@ -125,28 +127,30 @@ const NESTED_NOTE: &str = "its functions or colour spaces are built on one anoth
/// The error of a file with an array whose items can't all be read (a damaged file).
const UNREADABLE_NOTE: &str = "an array in it is damaged and can't be read";
/// Why `pdf` is refused, if it is: a stream the reader would fail on ([`STREAM_NOTE`]), or
/// Why `pdf` is refused, if it is: a stream the reader would fail on ([`stream_note`]), or
/// functions, color spaces or calculator procedures nested too deep ([`NESTED_NOTE`]). Every
/// object is looked at, and every array, dictionary and stream written inside one, wherever it
/// is used from: the interpreter builds functions and color spaces from resources, shadings,
/// images, patterns, masks and graphics states alike.
fn hostile(pdf: &Pdf) -> Option<&'static str> {
fn hostile(pdf: &Pdf) -> Option<String> {
let xref = pdf.xref();
let mut known = HashMap::new();
// The objects of the file, then what each holds directly (64 deep at most: the reader's cap).
let mut pending: Vec<Object<'_>> = pdf.objects().into_iter().collect();
while let Some(o) = pending.pop() {
// A stream's own checks come first: a calculator function is decoded below.
if matches!(&o, Object::Stream(s) if crate::streams::unsafe_stream(s)) {
return Some(STREAM_NOTE);
if let Object::Stream(s) = &o
&& let Some(r) = refused(s)
{
return Some(stream_note(pdf, s, r));
}
if procedures_too_deep(&o) || built_on(MaybeRef::NotRef(o.clone()), xref, &mut known, MAX_LINKS).is_none() {
return Some(NESTED_NOTE);
return Some(NESTED_NOTE.into());
}
match &o {
// An array with an item that can't be read can't be looked at whole, and the
// interpreter would still read its leading items: the file is refused.
Object::Array(a) if !readable(a) => return Some(UNREADABLE_NOTE),
Object::Array(a) if !readable(a) => return Some(UNREADABLE_NOTE.into()),
Object::Array(a) => pending.extend(items(a).filter_map(direct)),
_ => pending.extend(dict_or_stream(&o).into_iter().flat_map(|(d, _)| values(d)).filter_map(direct)),
}
@@ -154,6 +158,61 @@ fn hostile(pdf: &Pdf) -> Option<&'static str> {
None
}
/// The error of a file with the stream `s` the reader would fail on, as [`refused`] says why:
/// which object it is and what it holds, so a report of a file that can't be shared still says
/// which stream it is (#4366).
fn stream_note(pdf: &Pdf, s: &Stream<'_>, r: Refusal) -> String {
let id = s.obj_id();
// A stream written inside another object goes by that object; 0 is a stream the reader can't
// place.
let object = (id.obj_number > 0).then(|| format!("object {}", id.obj_number));
let what = match (r.inline_image, object) {
(true, Some(object)) => format!("an inline image in the content of {object}"),
(true, None) => "an inline image in it".to_string(),
(false, Some(object)) => format!("{} ({object})", stream_kind(pdf, id, s.dict())),
(false, None) => stream_kind(pdf, id, s.dict()),
};
match r.why {
Refused::TooBig(most) => format!("{what} would decode to more than {}", decoded_size(most)),
Refused::Parameters => format!("{what} has decode parameters that can't be applied"),
}
}
/// What the stream `id` with dictionary `dict` holds, for [`stream_note`]: an image (and its size
/// in pixels), a form, a page's content, the editing data an `.ai` keeps in its first page's
/// `PieceInfo`, or another stream.
fn stream_kind(pdf: &Pdf, id: ObjectIdentifier, dict: &Dict<'_>) -> String {
let subtype = dict.get::<Name<'_>>(b"Subtype");
let subtype_is = |v: &[u8]| subtype.as_ref().is_some_and(|n| n.as_ref() == v);
if subtype_is(b"Image") {
let side = |key: &[u8]| dict.get::<u32>(key).unwrap_or(0);
return format!("an image of {} × {} px in it", side(b"Width"), side(b"Height"));
}
if subtype_is(b"Form") {
return "a form in it".to_string();
}
let is = |m: Option<MaybeRef<Object<'_>>>| matches!(m, Some(MaybeRef::Ref(r)) if ObjectIdentifier::from(r) == id);
let pages = pdf.pages();
let content = pages.iter().any(|page| match page.raw().get_raw::<Object<'_>>(b"Contents") {
Some(MaybeRef::NotRef(Object::Array(a))) => items(&a).any(|m| is(Some(m))),
other => is(other),
});
if content {
return "a page's content in it".to_string();
}
let private = pages.first().and_then(|p| p.raw().get::<Dict<'_>>(b"PieceInfo")?.get::<Dict<'_>>(PRIVATE_DATA_OWNER)?.get::<Dict<'_>>(b"Private"));
if private.is_some_and(|d| d.entries().any(|(key, m)| key.as_ref().starts_with(b"AI") && is(Some(m)))) {
return "its editing data".to_string();
}
"a stream in it".to_string()
}
/// `bytes` for a message, in whole megabytes, or gigabytes when it is a whole number of them.
fn decoded_size(bytes: usize) -> String {
const GB: usize = 1 << 30;
if bytes >= GB && bytes.is_multiple_of(GB) { format!("{} GB", bytes / GB) } else { format!("{} MB", bytes >> 20) }
}
/// Read a PDF, decrypting it with `password` (none: the empty user password). The permissions
/// password opens a file too.
///
@@ -202,7 +261,7 @@ pub(crate) fn open_with_options(bytes: &[u8], password: Option<&str>, options: &
// Twice: looking at every object makes the reader repair a damaged table of objects (once
// only), and the objects are other ones after that.
.and_then(|pdf| match (0..2).find_map(|_| hostile(&pdf)) {
Some(why) => Err(PdfError::Parse(why.into())),
Some(why) => Err(PdfError::Parse(why)),
None => Ok(pdf),
})
}
@@ -366,3 +425,14 @@ fn piece_info_has(page: &Page<'_>, prefixes: &[&[u8]]) -> bool {
}
page.raw().get::<Dict<'_>>(b"PieceInfo").is_some_and(|d| walk(&d, prefixes, 0))
}
#[cfg(test)]
mod tests {
#[test]
fn decoded_sizes_read_in_whole_units() {
assert_eq!(super::decoded_size(256 << 20), "256 MB");
assert_eq!(super::decoded_size(418 << 20), "418 MB");
assert_eq!(super::decoded_size(1 << 30), "1 GB");
assert_eq!(super::decoded_size((1 << 30) + (1 << 20)), "1025 MB");
}
}
+30 -12
View File
@@ -234,34 +234,52 @@ fn maybe_content(dict: &Dict<'_>) -> bool {
&& ![&b"Length1"[..], b"Length2", b"Length3", b"FunctionType", b"N"].iter().any(|k| dict.contains_key(k))
}
/// Would the reader fail on `stream` (or on an inline image in it, when it is content): a
/// predictor it can't apply, or more than its [`budget`] decoded?
pub(crate) fn unsafe_stream(stream: &Stream<'_>) -> bool {
unsafe_data(stream, true)
/// Why the reader would fail on a stream ([`refused`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Refused {
/// A predictor whose decode parameters it can't apply ([`bad_predictor`]).
Parameters,
/// More than this many bytes decoded: the stream's [`budget`].
TooBig(usize),
}
/// [`unsafe_stream`], looking for inline images in it when `inside` (not in an inline image's own
/// A stream the reader would fail on: why, and whether it is an inline image in it that it would
/// fail on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Refusal {
pub why: Refused,
pub inline_image: bool,
}
/// Would the reader fail on `stream` (or on an inline image in it, when it is content): a
/// predictor it can't apply, or more than its [`budget`] decoded? Why, if so.
pub(crate) fn refused(stream: &Stream<'_>) -> Option<Refusal> {
refused_data(stream, true)
}
/// [`refused`], looking for inline images in it when `inside` (not in an inline image's own
/// data: it would be read again for each inline image a hostile one nests).
fn unsafe_data(stream: &Stream<'_>, inside: bool) -> bool {
fn refused_data(stream: &Stream<'_>, inside: bool) -> Option<Refusal> {
let dict = stream.dict();
let refusal = |why| Some(Refusal { why, inline_image: !inside });
if params(dict).iter().any(bad_predictor) {
return true;
return refusal(Refused::Parameters);
}
let content = inside && maybe_content(dict);
match decode(&stream.raw_data(), dict, content) {
Err(TooBig) => true,
Err(TooBig) => refusal(Refused::TooBig(budget(dict))),
Ok(Some(data)) if content && data.windows(2).any(|w| w == b"ID") => {
let mut ops = TypedIter::new(&data);
while let Some(op) = ops.next() {
if let TypedInstruction::InlineImage(image) = op
&& unsafe_data(image.0, false)
&& let Some(r) = refused_data(image.0, false)
{
return true;
return Some(r);
}
}
false
None
}
Ok(_) => false,
Ok(_) => None,
}
}
+24
View File
@@ -616,6 +616,30 @@ fn function_based_shadings_share_one_budget_per_import() {
assert!(r.warnings.iter().any(|w| w.contains("too many to read")), "{:?}", r.warnings);
}
/// A page drawing one small form over and over makes objects only up to the import's limit, then
/// leaves the rest out with a warning, instead of taking memory for every drawing (#3569).
#[test]
fn a_form_drawn_over_and_over_stops_at_the_import_s_objects() {
let squares: String = (0..25).map(|i| format!("{} {} 2 2 re f ", i % 5 * 4, i / 5 * 4)).collect();
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 20 20]", &squares);
let bytes = one_page(&"/F Do ".repeat(200), &format!("/XObject << /F {} 0 R >>", first_extra(1)), &[&form]);
// With the import's objects lowered to `limit` (on this thread: the limit is this thread's).
let open_within = |limit: usize| {
let old = crate::import::TEST_MAX_OBJECTS.replace(limit);
let r = crate::import::read(&bytes, &ImportOptions::default());
crate::import::TEST_MAX_OBJECTS.set(old);
r.unwrap()
};
let paths = |r: &ImportReport| all(&r.document).iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).count();
let left_out = |r: &ImportReport| r.warnings.iter().any(|w| w.contains("the rest were left out"));
let whole = open_within(10_000);
assert_eq!(paths(&whole), 5000);
assert!(!left_out(&whole), "{:?}", whole.warnings);
let cut = open_within(1000);
assert_eq!(paths(&cut), 1000);
assert!(left_out(&cut), "{:?}", cut.warnings);
}
/// A colour that changes in step with the sampling grid along y only (one colour at every grid
/// point) is still read along y, not as a flat gradient along x.
#[test]
+26 -5
View File
@@ -32,6 +32,8 @@ pub(crate) use output::sanitize_id;
use output::{entity_value, latin1_refs, valid_xml_value, xml_attr_escape, xml_text};
use text::FontUse;
const UNIQUE_PLACEHOLDER: &str = "u_vc_unique_-";
pub(crate) fn export(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>) -> Output {
// Live geometry effects (Roughen, Warp, Offset Path, Effect → Pathfinder…) export as their result.
let baked = vectorcraft_effects::bake_document(doc);
@@ -39,11 +41,30 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>
if opts.object_ids != ObjectIds::Unique {
return write(doc, opts, native, "");
}
// Unique ids: every id and class name gets a prefix hashed from the output written without
// one, so the same document always gets the same ids and different ones never share any.
let plain = write(doc, opts, native, "");
let prefix = format!("u{:010x}-", fnv1a(plain.svg.as_bytes()) & 0xff_ffff_ffff);
write(doc, opts, native, &prefix)
// Unique ids: write the body once with a placeholder prefix ([`UNIQUE_PLACEHOLDER`]),
// hash the result, and replace the placeholder with the computed prefix in a single pass.
let mut out = write(doc, opts, native, UNIQUE_PLACEHOLDER);
let prefix = format!("u{:010x}-", fnv1a(out.svg.as_bytes()) & 0xff_ffff_ffff);
out.svg = out.svg.replace(UNIQUE_PLACEHOLDER, &prefix);
if opts.preserve_editing && native.is_some() {
update_editing_hash(&mut out.svg);
}
out
}
/// After replacing the unique placeholder prefix, recompute the editing body hash so that
/// `editing(&svg).unwrap().intact` remains valid when `preserve_editing` is enabled.
fn update_editing_hash(svg: &mut String) {
let opts = usvg::roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
let Some(xml) = usvg::roxmltree::Document::parse_with_options(svg, opts).ok() else { return };
let Some(el) = xml.descendants().find(|n| n.tag_name().namespace() == Some(EDITING_NS) && n.tag_name().name() == "document") else { return };
let (Some(old_h), Some(meta)) = (el.attribute("hash"), el.parent_element()) else { return };
let r = meta.range();
let (head, tail) = (svg.get(..r.start).unwrap_or(""), svg.get(r.end..).unwrap_or(""));
let new_h = body_hash(&[head, tail]);
if new_h != old_h {
*svg = svg.replacen(&format!("hash=\"{old_h}\""), &format!("hash=\"{new_h}\""), 1);
}
}
/// `doc` as SVG with `opts`, the native file `native` embedded for Preserve Editing, its generated
+7 -3
View File
@@ -292,14 +292,18 @@ impl Writer<'_> {
format!("matrix({} {} {} {} {} {})", self.num(c[0]), self.num(c[1]), self.num(c[2]), self.num(c[3]), self.num(c[4]), self.num(c[5]))
}
/// How style properties are written: the styling option, but SVG Tiny has presentation
/// attributes alone.
pub(super) fn styling(&self) -> Styling {
if self.tiny() { Styling::PresentationAttributes } else { self.opts.styling }
}
/// Style properties as attributes, an inline style, or a CSS class, per the styling option.
pub(super) fn attrs(&mut self, props: &Props) -> String {
if props.is_empty() {
return String::new();
}
// SVG Tiny has presentation attributes alone.
let styling = if self.tiny() { Styling::PresentationAttributes } else { self.opts.styling };
match styling {
match self.styling() {
Styling::PresentationAttributes => {
// CSS-only properties go in a style attribute (SVG Tiny has none: left out).
let (style, attrs): (Vec<_>, Vec<_>) = props.iter().partition(|(k, _)| {
+6 -3
View File
@@ -1,8 +1,8 @@
use super::output::xml_text;
use super::{
Affine, AppearanceItem, Arc, BTreeSet, BezPath, BlendMode, CharStyle, FillRule, FontDb, FontFace, GradientKind, HashMap, Justify, Node, NodeId,
NodeKind, Paint, PathData, Props, Rect, StrokeAlign, TextKind, TextObject, Writer, base64_encode, css, css_string, fmt_num, font_descriptor,
xml_escape,
NodeKind, Paint, PathData, Props, Rect, StrokeAlign, Styling, TextKind, TextObject, Writer, base64_encode, css, css_string, fmt_num,
font_descriptor, xml_escape,
};
/// `chars` (sorted) as a CSS `unicode-range` (`U+41-5A,U+61`).
@@ -36,7 +36,10 @@ impl Writer<'_> {
/// (as on the canvas).
fn char_props(&mut self, st: &CharStyle, space: (Rect, Affine)) -> Props {
let decimals = self.opts.decimals;
let len = |v: f64| fmt_num(v, decimals);
// Only presentation attributes take a bare number (in user units). Written as CSS, a
// length needs its unit, a user unit being a `px`: a browser drops `font-size:8.6` (#3209).
let unit = if self.styling() == Styling::PresentationAttributes { "" } else { "px" };
let len = |v: f64| format!("{}{unit}", fmt_num(v, decimals));
let mut p = css::font_props(st, &len);
// Character paints resolve in the text's space (`to_user` maps it into user space).
let (bounds, to_user) = space;
+10 -2
View File
@@ -228,8 +228,16 @@ fn shared_properties_read_as_svg_export_writes_them() {
let decl = format!("{k}:{v}").replace('"', "&quot;").replace('\'', "&apos;");
assert!(style.contains(&decl), "{decl} as in SVG export: {style}");
}
// Lengths are the same numbers, with units on the web.
assert!(style.contains("font-size:18") && prop(&r, "font-size") == Some("18px"), "{style}");
// Lengths are the same, with units: CSS takes a bare number only for 0, and a browser drops
// `font-size:18`, leaving the type at its default size (#3209). Presentation attributes take
// bare numbers (`text_layout`).
let spacing = prop(&r, "letter-spacing").unwrap_or_default();
assert!(prop(&r, "font-size") == Some("18px") && spacing.ends_with("px"), "{r:?}");
for decl in ["font-size:18px".to_string(), format!("letter-spacing:{spacing}")] {
assert!(style.contains(&decl), "{decl} as in SVG export: {style}");
}
let inline = export(&d, &ExportOptions { styling: Styling::InlineStyle, ..ExportOptions::default() });
assert!(inline.contains("font-size:18px") && inline.contains(&format!("letter-spacing:{spacing}")), "{inline}");
}
#[test]
+23
View File
@@ -136,3 +136,26 @@ fn label_attributes_name_objects() {
assert_eq!(d.layers[0].name.as_deref(), Some("Background"));
assert_eq!(names(&d), ["Background", "Sky box", "Sun disc", "Badge", "plain"]);
}
#[test]
fn unique_ids_differ_when_only_gradient_stops_differ() {
let make_doc = |stop_color: Color| {
let mut d = Document::new(200.0, 200.0);
d.title = "Identical Structure".into();
let l = d.layers[0].id;
let stops = vec![GradientStop::new(0.0, stop_color), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)), Appearance::basic(g, Paint::None, 0.0));
d.insert(Some(l), usize::MAX, n).unwrap();
d
};
let opts = ExportOptions { object_ids: ObjectIds::Unique, ..Default::default() };
let svg1 = export(&make_doc(Color::rgb(1.0, 0.0, 0.0)), &opts);
let svg2 = export(&make_doc(Color::rgb(0.0, 1.0, 0.0)), &opts);
let ids1 = ids(&svg1);
let ids2 = ids(&svg2);
assert!(!ids1.is_empty() && !ids2.is_empty());
assert!(ids1.iter().all(|id| !ids2.contains(id)), "IDs must not collide: {ids1:?} vs {ids2:?}");
}
+1 -1
View File
@@ -55,7 +55,7 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU (wgpu, through eframe) only composites the canvas texture and draws the UI, so any GPU that can show the window will do. The desktop app picks the window's adapter itself (`apps/vectorcraft/src/gpu.rs`, eframe's `native_adapter_selector`) and logs every adapter it found and the one it uses.
- **Order:** adapters that report they can't present to the window are never tried; then the one `WGPU_ADAPTER_NAME` names; hardware before software (llvmpipe, WARP); the native backends (Vulkan, Metal, DX12) before OpenGL; with Automatic, the GPU the Wayland compositor draws with, then the GPU that drives a display, and among those the one that drives the primary display; then the power preference; then a GPU's DX12 adapter before its Vulkan one (Intel's Vulkan driver made the whole window flicker black, #545), keeping the system's order among equals.
- **Windows backends:** Direct3D 12, falling back to OpenGL. Vulkan isn't started at all unless `WGPU_BACKEND` asks for it (`WGPU_BACKEND=vulkan`): creating a Vulkan instance loads every installed Vulkan driver into the process, and a faulty one (Intel's `igvk64.dll`, #806) crashed the app before its window appeared. After a start that crashed while it listed the Direct3D 12 adapters, later starts list OpenGL's alone (see the crash bullet below).
- **Windows backends:** Direct3D 12. OpenGL is listed beside it only when a start leaves an adapter out (a crash or a failed restart) or Direct3D 12 lists no adapter (Windows 7): wgpu's OpenGL backend keeps a hidden window whose thread pumps no messages, and the app hung on a switch to another input language (#4150, gfx-rs/wgpu#10504). Vulkan isn't started at all unless `WGPU_BACKEND` asks for it (`WGPU_BACKEND=vulkan`): creating a Vulkan instance loads every installed Vulkan driver into the process, and a faulty one (Intel's `igvk64.dll`, #806) crashed the app before its window appeared. After a start that crashed while it listed the Direct3D 12 adapters, later starts list OpenGL's alone (see the crash bullet below).
- **The GPU that drives the display.** A GPU without a monitor is the wrong one even when it says it can show the window: on a desktop with a Ryzen's integrated GPU (no monitor) and an NVIDIA card driving both monitors, power saving picked the integrated GPU, Windows had to present every frame across adapters, and the integrated GPU's driver reset under that: the graphics device was lost, or the desktop's compositor went down and every monitor stayed black for minutes (pdfcraft#378). So Automatic asks the system which GPU drives which display and puts that GPU first; the power preference only orders the rest, and decides alone where the system can't say.
- **Windows:** `EnumDisplayDevices` (the display devices attached to the desktop, and the primary one), through the `winsafe` crate; the PCI ids in each device id are matched against wgpu's adapters. The registry doesn't tell a user which adapter drives a display, and WMI reports a resolution for a GPU without a monitor, so neither is used.
- **Linux:** the connected connectors in `/sys/class/drm` and their cards' PCI ids; a built-in panel (`eDP`, `LVDS`, `DSI`) counts as the primary display.