Clarify PDF import stages and module dependencies

This commit is contained in:
Dylan M. Taylor
2026-10-28 05:02:46 +00:00
parent 89d538e40c
commit 317d270823
12 changed files with 161 additions and 94 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
use super::writer::{Sheet, Writer, date_time};
use super::*;
use super::{Affine, Document, ExportReport, Exporter, HashSet, PageBoxes, PdfError, PdfOptions, Rect, write};
/// Export `doc` as PDF bytes: one page per artboard (or the artboards chosen in `opts`).
pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
+4 -1
View File
@@ -1,5 +1,8 @@
use super::entry::RgbInCmyk;
use super::*;
use super::{
Affine, Arc, BezPath, BlendMode, Color, Compatibility, Document, Exporter, Fill, FillRule, HashMap, HashSet, Node, NormalizedF32, Path,
PathBuilder, PathEl, Rect, Shape, Standard, Surface, Transform, cmyk, krilla, luma, rgb,
};
/// How deep inline graphics nest before they are left out.
pub(super) const MAX_INLINE_DEPTH: u32 = 4;
+1 -1
View File
@@ -1,5 +1,5 @@
use super::exporter::{UNDECODED, writable, xf};
use super::*;
use super::{BlendMode, Exporter, Image, NodeKind, Rect, Size, Surface};
impl Exporter<'_> {
/// Image `im` placed `size` points wide and high, resampled and compressed as the Compression
+4 -1
View File
@@ -1,5 +1,8 @@
use super::exporter::{address, blend, constant_mask, cover, in_range, norm, rects_overlap, rule, to_path, writable};
use super::*;
use super::{
Affine, AppearanceItem, BezPath, BlendMode, Exporter, Fill, FillRule, LineCap, LineJoin, Mark, Node, NodeKind, NormalizedF32, Paint, Path, Rect,
Shape, Stroke, StrokeAlign, StrokeDash, StrokeLayer, Surface, WrittenShape, krilla, stroke,
};
impl Exporter<'_> {
/// Opacity mask → luminosity soft mask. Outside the art the backdrop is black (clip) or white;
+4 -1
View File
@@ -1,5 +1,8 @@
use super::exporter::{MAX_FIELD_PX, MAX_GRADIENT_PX, MAX_PATTERN_DEPTH, color, ctm, in_range, norm, q, to_path, writable, xf};
use super::*;
use super::{
Affine, Color, Exporter, GradientKind, LinearGradient, Paint, RadialGradient, Rect, SepColor, SeparationColorant, SeparationSpace, Shape, Size,
SpreadMethod, Stop, Surface, Vec2, cmyk, krilla, rgb,
};
impl Exporter<'_> {
/// A colour for the page, as the Output settings write it ([`ColorOut::color`]): without
+4 -1
View File
@@ -1,6 +1,9 @@
use super::exporter::{GlyphLine, MAX_INLINE_DEPTH, in_range, norm, to_path, writable, xf};
use super::images::embed;
use super::*;
use super::{
Affine, AppearanceItem, BezPath, BlendMode, Color, Cow, Embedding, Exporter, Fill, FillRule, Glyph, GlyphId, KrillaGlyph, ListMarker, Mark, Node,
NormalizedF32, Paint, Path, Rect, Shape, Size, Surface, TextLayout, TextObject, krilla, write,
};
impl Exporter<'_> {
///
+4 -1
View File
@@ -1,6 +1,9 @@
use super::entry::RgbInCmyk;
use super::exporter::{constant_mask, cover, to_path, writable, xf};
use super::*;
use super::{
Affine, Arc, Archival, ColorOut, Compatibility, Document, Exporter, HashSet, Image, Metadata, Node, PageSettings, PdfError, PdfVersion, Rect,
Shape, Size, Standard, find, krilla, rfind, write,
};
/// A PDF being written, shared by export and print: the file's configuration and metadata, then
/// its pages ([`Self::page`]), each drawn by an [`Exporter`].
+6 -1
View File
@@ -1,7 +1,12 @@
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::state::{Builder, Frame, FrameKind, Saved, bounds, grouped, line_width, mean_scale, paints, round3, stroke_layer};
use super::*;
use super::{
Affine, Appearance, AppearanceItem, Arc, BezPath, BfString, BlendMode, CacheKey, Color, Colors, Families, FillLayer, FillRule, FunctionBudget,
FunctionPaint, HashMap, HashSet, ImageBlob, ImageObject, Knockout, Look, MaskSpec, MaskType, Native, Node, NodeId, NodeKind, OpacityMask, Paint,
PathData, PathDrawMode, Pattern, PatternDef, Placement, Rect, Scan, Shape, SoftMask, StrokeProps, TextAs, TextLine, TextOverprint, TilingPattern,
Upright, Walked, clipped, contains, fold_stop_opacity, function_paint, gradient_shape, is_rectangle, luminance, mask_spec, shading_gradient,
};
impl<'p> Builder<'p> {
pub(super) fn new(next: u64, colors: Colors<'p>, text_as: TextAs, route: bool) -> Self {
+3 -1
View File
@@ -1,4 +1,6 @@
use super::*;
use super::{
Affine, Arc, BezPath, ClipPath, Context, Device, Glyph, GlyphDrawMode, HashMap, Image, Page, PathDrawMode, PathEl, Rect, Shape, SoftMask,
};
/// How many paths the interpreter draws on `page` (each fill and each stroke), leaving out those
/// of soft masks, pattern cells and type 3 glyphs, which [`Builder`] reads nested. `None` when the
+4 -1
View File
@@ -2,7 +2,10 @@ use super::entry::{INVISIBLE_TEXT_NOTE, MAX_NESTED, OUTLINED_TEXT_NOTE, UNSORTED
use super::state::{
Builder, Decoded, Frame, FrameKind, GlyphRun, blend, fill_rule, line_width, mean_scale, resize_alpha, rgba_png, same_outline, stroke_layer,
};
use super::*;
use super::{
Affine, Appearance, AppearanceItem, Arc, BezPath, BlendMode, ClipPath, Device, FillLayer, FillRule, Glyph, GlyphDrawMode, Image, ImageBlob,
ImageData, Name, Node, NodeKind, PathData, PathDrawMode, Pattern, Shape, SoftMask, clipped, extend_clip, mesh_shading, tag_key,
};
impl<'a> Device<'a> for Builder<'_> {
fn set_soft_mask(&mut self, mask: Option<SoftMask<'a>>) {
+122 -83
View File
@@ -1,6 +1,10 @@
use super::cid::drawn_paths;
use super::state::Builder;
use super::*;
use super::{
Affine, Arc, Artboard, AtomicBool, BlendMode, ColorMode, Colors, Context, CropTo, Document, FillRule, HashMap, HashSet, ImportOptions,
ImportReport, InterpreterCache, InterpreterSettings, InterpreterWarning, LayerColor, MAX_NESTING, Mutex, Node, NodeKind, Ocgs, Ordering, Paint,
PdfError, Rect, Shape, Swatch, all_on, contains, hides_forms, scan_page, white_cover,
};
/// Mixed into an image's cache key when it is read white inside an alpha soft mask.
pub(super) const WHITE_KEY: u128 = 0x7717_e000_a1fa;
@@ -182,54 +186,12 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p
let picked = crate::pages::picked(opts, original.pages().len())?;
let mut ocgs = Ocgs::read(&original);
let route = opts.layers && !ocgs.list.is_empty();
// Content that is off imports as hidden layers: an update of the file turns every group on
// (unless a form is hidden by its own group: its art couldn't be told apart).
let hide = route && ocgs.skips_any();
let mut hidden_form = false;
if hide {
for page in picked.iter().filter_map(|&n| original.pages().get(n)) {
hidden_form |= hides_forms(page, &mut ocgs, original.decode_limits().max_decoded_stream())?;
if hidden_form {
break;
}
}
}
let turned_on = (hide && !hidden_form)
.then(|| all_on(bytes, &original))
.flatten()
.and_then(|b| crate::pages::open_with_options(&b, opts.password.as_deref(), reader).ok())
.filter(|p| Ocgs::read(p).list.is_empty());
let mut notes = vec![];
if hide && turned_on.is_none() {
notes.push("the art of hidden layers couldn't be read and was left out".to_string());
}
let all_on = turned_on.is_some();
let pdf = turned_on.unwrap_or(original);
let (pdf, all_on, notes) = reopen_with_groups_on(bytes, opts, reader, original, &picked, &mut ocgs, route)?;
let pages = pdf.pages();
let sink: Arc<Mutex<Vec<String>>> = Arc::default();
let sink2 = sink.clone();
let incomplete = Arc::new(AtomicBool::new(false));
let incomplete_sink = incomplete.clone();
let settings = InterpreterSettings {
warning_sink: Arc::new(move |w| {
let msg = match w {
InterpreterWarning::UnsupportedFont => "a font could not be read; its text was skipped",
InterpreterWarning::ImageDecodeFailure => "an image could not be decoded and was skipped",
InterpreterWarning::ContentTruncated => {
incomplete_sink.store(true, Ordering::Relaxed);
return;
}
};
if let Ok(mut v) = sink2.lock()
&& !v.iter().any(|x| x == msg)
{
v.push(msg.to_string());
}
}),
render_annotations: false,
..Default::default()
};
let settings = interpreter_settings(sink.clone(), incomplete.clone());
let mut doc = Document::new(1.0, 1.0);
doc.title = "Imported PDF".into();
@@ -316,17 +278,7 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p
// crossing the artboard edge isn't clipped, so it opens whole (#646).
let shown = xf.transform_rect_bbox(crate::pages::page_box(page, CropTo::Crop));
let page_box = shown.inflate(0.01, 0.01);
let mut clipped = false;
for (_, art) in parts.iter_mut().filter(|_| !ai) {
for n in art.iter_mut() {
if n.visual_bounds().is_some_and(|r| !contains(page_box, r)) {
let clip = Arc::new(b.clip_node(&shown.to_path(0.1), FillRule::NonZero));
*n = Arc::new(Node::new(b.id(), NodeKind::Group { children: vec![clip, n.clone()], clip: true }));
clipped = true;
}
}
}
if clipped {
if !ai && clip_past_page(&mut parts, &mut b, shown, page_box) {
b.warn(PAST_PAGE_NOTE);
}
// A `.ai` page keeps its art past the artboard edge, unclipped (#646), so a page whose art
@@ -336,24 +288,8 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p
// art clears the art before it.
let mut art_right = f64::NEG_INFINITY;
if ai && let Some(art) = parts.iter().flat_map(|(_, v)| v.iter()).filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)) {
// Art at a coordinate that isn't a number (a damaged file) moves nothing.
let dx = Some((clear - art.x0).max(0.0)).filter(|dx| dx.is_finite()).unwrap_or(0.0);
if dx > 0.0 {
let shift = Affine::translate((dx, 0.0));
for (_, v) in &mut parts {
for n in v.iter_mut() {
Arc::make_mut(n).transform(shift, false);
}
}
// What the page's lines of type were is told where their glyphs are (the outlines
// stroked on them are looked for there), so that moves with them.
for facts in b.lines.iter_mut().filter(|(id, _)| id.0 >= first_id).map(|(_, facts)| facts) {
for ink in &mut facts.ink {
*ink = *ink + kurbo::Vec2::new(dx, 0.0);
}
}
ab = Rect::new(ab.x0 + dx, ab.y0, ab.x1 + dx, ab.y1);
}
let dx = shift_ai_page(&mut parts, &mut b, first_id, clear, art);
ab = Rect::new(ab.x0 + dx, ab.y0, ab.x1 + dx, ab.y1);
art_right = art.x1 + dx;
}
let children: Vec<Arc<Node>> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect();
@@ -416,15 +352,7 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p
}
// The groups' layers stack as the pages mark them (the art outside them stays where it was
// drawn): a group that first paints on a later page doesn't go on top of the others.
let rank: HashMap<usize, usize> = order.iter().enumerate().map(|(i, &g)| (g, i)).collect();
let at: Vec<usize> = slots.iter().enumerate().filter(|(_, s)| matches!(s, Slot::Group(_))).map(|(i, _)| i).collect();
let mut stacked: Vec<usize> = slots.iter().filter_map(|s| if let Slot::Group(g) = s { Some(*g) } else { None }).collect();
stacked.sort_by_key(|g| rank.get(g).copied().unwrap_or(usize::MAX));
for (i, g) in at.into_iter().zip(stacked) {
if let Some(s) = slots.get_mut(i) {
*s = Slot::Group(g);
}
}
stack_groups(&mut slots, &order);
for (n, slot) in slots.into_iter().enumerate() {
let mut layer = match slot {
Slot::Page(l) => *l,
@@ -528,3 +456,114 @@ fn is_only_text(nodes: &[Arc<Node>]) -> bool {
let mut text = false;
walk(nodes, &mut text) && text
}
/// Opens the file again with every layer group turned on when the pages hide art in groups that are
/// off, and tells whether it did. Gives the file to read, whether it is the turned-on copy, and the
/// notes for the import's report.
fn reopen_with_groups_on(
bytes: &[u8],
opts: &ImportOptions,
reader: &craft_pdf::OpenOptions,
original: craft_pdf::syntax::Pdf,
picked: &[usize],
ocgs: &mut Ocgs,
route: bool,
) -> Result<(craft_pdf::syntax::Pdf, bool, Vec<String>), PdfError> {
// Content that is off imports as hidden layers: an update of the file turns every group on
// (unless a form is hidden by its own group: its art couldn't be told apart).
let hide = route && ocgs.skips_any();
let mut hidden_form = false;
if hide {
for page in picked.iter().filter_map(|&n| original.pages().get(n)) {
hidden_form |= hides_forms(page, ocgs, original.decode_limits().max_decoded_stream())?;
if hidden_form {
break;
}
}
}
let turned_on = (hide && !hidden_form)
.then(|| all_on(bytes, &original))
.flatten()
.and_then(|b| crate::pages::open_with_options(&b, opts.password.as_deref(), reader).ok())
.filter(|p| Ocgs::read(p).list.is_empty());
let mut notes = vec![];
if hide && turned_on.is_none() {
notes.push("the art of hidden layers couldn't be read and was left out".to_string());
}
let all_on = turned_on.is_some();
Ok((turned_on.unwrap_or(original), all_on, notes))
}
/// The interpreter's settings, its warnings noted once each in `sink` (truncated content in `incomplete`).
fn interpreter_settings(sink: Arc<Mutex<Vec<String>>>, incomplete: Arc<AtomicBool>) -> InterpreterSettings {
InterpreterSettings {
warning_sink: Arc::new(move |w| {
let msg = match w {
InterpreterWarning::UnsupportedFont => "a font could not be read; its text was skipped",
InterpreterWarning::ImageDecodeFailure => "an image could not be decoded and was skipped",
InterpreterWarning::ContentTruncated => {
incomplete.store(true, Ordering::Relaxed);
return;
}
};
if let Ok(mut v) = sink.lock()
&& !v.iter().any(|x| x == msg)
{
v.push(msg.to_string());
}
}),
render_annotations: false,
..Default::default()
}
}
/// Moves a `.ai` page's art (`art`, its bounds) right until it clears the art before it (at
/// `clear`), and its lines of type with it. Gives how far.
fn shift_ai_page(parts: &mut [(Option<usize>, Vec<Arc<Node>>)], b: &mut Builder<'_>, first_id: u64, clear: f64, art: Rect) -> f64 {
// Art at a coordinate that isn't a number (a damaged file) moves nothing.
let dx = Some((clear - art.x0).max(0.0)).filter(|dx| dx.is_finite()).unwrap_or(0.0);
if dx > 0.0 {
let shift = Affine::translate((dx, 0.0));
for (_, v) in parts.iter_mut() {
for n in v.iter_mut() {
Arc::make_mut(n).transform(shift, false);
}
}
// What the page's lines of type were is told where their glyphs are (the outlines
// stroked on them are looked for there), so that moves with them.
for facts in b.lines.iter_mut().filter(|(id, _)| id.0 >= first_id).map(|(_, facts)| facts) {
for ink in &mut facts.ink {
*ink = *ink + kurbo::Vec2::new(dx, 0.0);
}
}
}
dx
}
/// Clips each object reaching past `page_box` to `shown`. Tells whether any did.
fn clip_past_page(parts: &mut [(Option<usize>, Vec<Arc<Node>>)], b: &mut Builder<'_>, shown: Rect, page_box: Rect) -> bool {
let mut clipped = false;
for (_, art) in parts.iter_mut() {
for n in art.iter_mut() {
if n.visual_bounds().is_some_and(|r| !contains(page_box, r)) {
let clip = Arc::new(b.clip_node(&shown.to_path(0.1), FillRule::NonZero));
*n = Arc::new(Node::new(b.id(), NodeKind::Group { children: vec![clip, n.clone()], clip: true }));
clipped = true;
}
}
}
clipped
}
/// The groups' layers stack as the pages mark them (`order`, bottom first).
fn stack_groups(slots: &mut [Slot], order: &[usize]) {
let rank: HashMap<usize, usize> = order.iter().enumerate().map(|(i, &g)| (g, i)).collect();
let at: Vec<usize> = slots.iter().enumerate().filter(|(_, s)| matches!(s, Slot::Group(_))).map(|(i, _)| i).collect();
let mut stacked: Vec<usize> = slots.iter().filter_map(|s| if let Slot::Group(g) = s { Some(*g) } else { None }).collect();
stacked.sort_by_key(|g| rank.get(g).copied().unwrap_or(usize::MAX));
for (i, g) in at.into_iter().zip(stacked) {
if let Some(s) = slots.get_mut(i) {
*s = Slot::Group(g);
}
}
}
+4 -1
View File
@@ -1,5 +1,8 @@
use super::cid::{CidText, FontInfo};
use super::*;
use super::{
Affine, Arc, BezPath, BlendMode, Colors, Dash, Families, FillRule, FontNames, FunctionBudget, HashMap, HashSet, ImageBlob, LineCap, LineFacts,
LineJoin, LumaData, MaskSpec, Node, NodeId, Paint, PathEl, PatternDef, Rect, Scan, StrokeLayer, StrokeProps, TextAs, TextLine,
};
/// Decoded RGBA pixels, width, height and hayro's scale factors.
pub(super) type Decoded = (Vec<u8>, u32, u32, (f32, f32));