Phase 8 #5 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md ("Advanced transparency"). Design and merge criteria in REVIEWS/adr/0009-pdf-transparency.md. The headline defect was not that transparency was missing. `gs` was MIS-PARSED as CloseStroke: /GS0 gs 1 0 0 rg 100 100 200 200 re f -> [CloseStroke, RgbFill, Rectangle, FillWinding] so every page setting a graphics state gained a stroked path the document never asked for, and lost its alpha, blend mode and soft mask silently. Downstream everything was dead: StrokeExtGState/FillExtGState were never constructed, set_fill_opacity was never called and emitted no command when it was, and PdfPage::ext_gstate was read by no code at all. New pdf-graphics/src/transparency.rs: full /ExtGState (ca, CA, BM, SMask, LW, LC, LJ, ML, D, AIS, TK), all sixteen blend modes including the four non-separable ones, soft masks with /S, /G, /BC and a /TR evaluated through ADR 0006's PdfFunction, and /Group parsing. Wired end to end: parser -> PdfOp::SetExtGState -> device -> RenderCommand -> Makepad renderer. Compositing is NOT claimed. Backdrop blending needs render-to-texture, which an engine-neutral crate has no framebuffer for. Constant alpha is applied because it needs no backdrop; blend modes and soft masks are reported through TransparencyError::Unsupported rather than dropped, because a silently ignored /Multiply looks exactly like a correct /Normal. The ADR required a test enumerating every multi-character operator, on the grounds that fixing the third instance of a shadowing bug (after cm and rg) without preventing the fourth is not a fix. It immediately found three more live bugs, none of them transparency-related: - `b` and `b*` dropped their close-path, mapping to FillStroke* instead of CloseFillStroke*, so every closed-and-stroked path drew with a gap. - Text operators within three bytes of the end of a content stream were mis-parsed: the b'T' arm guarded `*i + 3 < len` while reading only two bytes, so a stream ending in `/F1 12 Tf` parsed as FillWinding. And the transparency-group fixture found a fourth: - PdfPage::xobjects was empty for every page of every document. extract_xobjects called doc.resolve(), which follows the reference, then asked the resolved object for as_ref() - always None - and only accepted a bare dict when every XObject is a stream. No `Do` operator could be resolved through the page model. Same defect as the one that once destroyed annotation object references; it now has its own regression test. T* and BMC are genuinely unimplemented and are deliberately excluded from the guard's table rather than papered over. 7 corpus fixtures, 12 acceptance tests, 32 unit tests asserting the §11.3.5 formulas (not our own output), and a parse_ext_gstate fuzz target. TEST_TARGET=pdf 451 -> 497, TEST_TARGET=pdf-ui 495 -> 541. rustfmt and clippy -D warnings clean.
321 lines
11 KiB
Rust
321 lines
11 KiB
Rust
//! Phase 7 exit criterion.
|
|
//!
|
|
//! > Large PDF (50+ pages) loads and renders first page in <200ms. Rapid
|
|
//! > document switching doesn't leak memory or show stale pages.
|
|
//!
|
|
//! The timing assertion uses a deliberately loose budget. A CI runner is
|
|
//! shared and unpredictable, so a tight threshold would produce flaky
|
|
//! failures that get muted, and a muted test is worse than no test. The
|
|
//! budget here catches an order-of-magnitude regression — re-parsing every
|
|
//! page to show the first one, say — which is the failure the review is
|
|
//! actually guarding against.
|
|
|
|
use std::time::Instant;
|
|
|
|
use nigig_pdf_document::PdfDocument;
|
|
use nigig_pdf_graphics::cache::{CachedPage, PageCache};
|
|
use nigig_pdf_graphics::content::parse_content_stream;
|
|
use nigig_pdf_graphics::recording::RecordingDevice;
|
|
use nigig_pdf_graphics::worker::{PageState, PendingPages, RenderRequest, RenderWorker};
|
|
|
|
const LARGE: &[u8] = include_bytes!("../../tests/corpus/perf/large_60_pages.pdf");
|
|
|
|
/// The review's threshold. Generous relative to the real figure so that
|
|
/// shared-runner noise cannot make this flaky.
|
|
const FIRST_PAGE_BUDGET_MS: u128 = 200;
|
|
|
|
#[test]
|
|
fn the_large_fixture_really_is_large() {
|
|
let doc = PdfDocument::parse(LARGE).expect("fixture parses");
|
|
assert!(
|
|
doc.page_count() >= 50,
|
|
"the exit criterion needs 50+ pages, found {}",
|
|
doc.page_count()
|
|
);
|
|
}
|
|
|
|
/// The exit criterion's timing clause.
|
|
#[test]
|
|
fn the_first_page_of_a_large_document_renders_within_budget() {
|
|
let start = Instant::now();
|
|
|
|
let mut doc = PdfDocument::parse(LARGE).expect("parses");
|
|
let page = doc.page(0).expect("page 0");
|
|
let ops = parse_content_stream(&page.content_data).expect("content parses");
|
|
let commands = RecordingDevice::from_ops(&ops);
|
|
|
|
let elapsed = start.elapsed();
|
|
assert!(
|
|
!commands.is_empty(),
|
|
"page 0 must actually have rendered something"
|
|
);
|
|
assert!(
|
|
elapsed.as_millis() < FIRST_PAGE_BUDGET_MS,
|
|
"first page took {}ms, budget is {FIRST_PAGE_BUDGET_MS}ms",
|
|
elapsed.as_millis()
|
|
);
|
|
println!(
|
|
"first page of {} pages in {}ms",
|
|
doc.page_count(),
|
|
elapsed.as_millis()
|
|
);
|
|
}
|
|
|
|
/// Opening the document must not cost the whole document.
|
|
#[test]
|
|
fn opening_a_large_document_does_not_render_every_page() {
|
|
// Parsing the document and reaching page 0 must be far cheaper than
|
|
// rendering all 60 pages. If someone makes open() eager, this fails.
|
|
let open_start = Instant::now();
|
|
let mut doc = PdfDocument::parse(LARGE).expect("parses");
|
|
let page_count = doc.page_count();
|
|
let first = doc.page(0).expect("page 0");
|
|
let _ = parse_content_stream(&first.content_data).expect("parses");
|
|
let open_ms = open_start.elapsed().as_secs_f64() * 1000.0;
|
|
|
|
let all_start = Instant::now();
|
|
for index in 0..page_count {
|
|
if let Ok(page) = doc.page(index) {
|
|
if let Ok(ops) = parse_content_stream(&page.content_data) {
|
|
let _ = RecordingDevice::from_ops(&ops);
|
|
}
|
|
}
|
|
}
|
|
let all_ms = all_start.elapsed().as_secs_f64() * 1000.0;
|
|
|
|
println!("open+first {open_ms:.1}ms, all {page_count} pages {all_ms:.1}ms");
|
|
// Rendering everything must cost meaningfully more than the first page,
|
|
// or the "first page" path is secretly doing all the work.
|
|
assert!(
|
|
all_ms > open_ms,
|
|
"rendering {page_count} pages ({all_ms:.1}ms) should cost more than \
|
|
opening and rendering one ({open_ms:.1}ms)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_cached_page_is_much_cheaper_than_re_parsing_it() {
|
|
let mut doc = PdfDocument::parse(LARGE).expect("parses");
|
|
let mut cache = PageCache::new(8 << 20);
|
|
let generation = cache.begin_document();
|
|
|
|
// Cold: parse and interpret.
|
|
let cold_start = Instant::now();
|
|
let page = doc.page(0).expect("page 0");
|
|
let ops = parse_content_stream(&page.content_data).expect("parses");
|
|
let commands = RecordingDevice::from_ops(&ops);
|
|
let cold = cold_start.elapsed();
|
|
|
|
cache.insert(
|
|
generation,
|
|
0,
|
|
CachedPage {
|
|
commands,
|
|
images: Default::default(),
|
|
page_height: page.height(),
|
|
appearance_dirty: false,
|
|
},
|
|
);
|
|
|
|
// Warm: read the command list back.
|
|
let warm_start = Instant::now();
|
|
let cached = cache.get(0).expect("cached");
|
|
let count = cached.commands.len();
|
|
let warm = warm_start.elapsed();
|
|
|
|
assert!(count > 0);
|
|
println!("cold {}us, warm {}us", cold.as_micros(), warm.as_micros());
|
|
assert!(
|
|
warm < cold,
|
|
"replaying a cached page ({}us) must beat re-parsing it ({}us)",
|
|
warm.as_micros(),
|
|
cold.as_micros()
|
|
);
|
|
}
|
|
|
|
/// The exit criterion's "doesn't leak memory" clause.
|
|
#[test]
|
|
fn rapid_document_switching_does_not_grow_memory() {
|
|
const BUDGET: usize = 1 << 20;
|
|
let mut cache = PageCache::new(BUDGET);
|
|
let mut doc = PdfDocument::parse(LARGE).expect("parses");
|
|
|
|
for _ in 0..30 {
|
|
let generation = cache.begin_document();
|
|
// Render a handful of pages of each "document".
|
|
for index in 0..6 {
|
|
let Ok(page) = doc.page(index) else { continue };
|
|
let Ok(ops) = parse_content_stream(&page.content_data) else {
|
|
continue;
|
|
};
|
|
cache.insert(
|
|
generation,
|
|
index,
|
|
CachedPage {
|
|
commands: RecordingDevice::from_ops(&ops),
|
|
images: Default::default(),
|
|
page_height: page.height(),
|
|
appearance_dirty: false,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// After 30 switches the cache must be bounded by its budget, not by the
|
|
// total of everything ever rendered.
|
|
assert!(
|
|
cache.used_bytes() <= BUDGET * 2,
|
|
"cache holds {} bytes after 30 document switches, budget {BUDGET}",
|
|
cache.used_bytes()
|
|
);
|
|
assert!(cache.len() <= 6, "only the last document's pages remain");
|
|
}
|
|
|
|
/// The exit criterion's "doesn't show stale pages" clause, end to end
|
|
/// through the worker.
|
|
#[test]
|
|
fn a_page_from_an_abandoned_document_is_never_shown() {
|
|
let mut doc = PdfDocument::parse(LARGE).expect("parses");
|
|
let mut cache = PageCache::new(8 << 20);
|
|
let worker = RenderWorker::spawn();
|
|
|
|
// Document A: queue several pages.
|
|
let doc_a = cache.begin_document();
|
|
for index in 0..8 {
|
|
let Ok(page) = doc.page(index) else { continue };
|
|
worker.request(RenderRequest {
|
|
generation: doc_a,
|
|
page_index: index,
|
|
content: page.content_data.clone(),
|
|
page_height: page.height(),
|
|
color_spaces: page.color_spaces.clone(),
|
|
ext_gstates: page.ext_gstates.clone(),
|
|
});
|
|
}
|
|
|
|
// The user immediately switches to document B.
|
|
let doc_b = cache.begin_document();
|
|
|
|
// Every result from A must be refused, however late it arrives.
|
|
let mut refused = 0;
|
|
for _ in 0..8 {
|
|
let Some(result) = worker.recv_blocking() else {
|
|
break;
|
|
};
|
|
if result.generation == doc_a {
|
|
assert!(
|
|
!cache.insert(result.generation, result.page_index, result.page),
|
|
"a page of the abandoned document must not enter the cache"
|
|
);
|
|
refused += 1;
|
|
}
|
|
}
|
|
assert_eq!(refused, 8, "all eight stale pages must be refused");
|
|
assert!(
|
|
cache.is_empty(),
|
|
"nothing from the abandoned document may be shown"
|
|
);
|
|
assert!(cache.accepts(doc_b));
|
|
}
|
|
|
|
#[test]
|
|
fn pages_show_a_placeholder_until_their_render_arrives() {
|
|
let mut doc = PdfDocument::parse(LARGE).expect("parses");
|
|
let mut cache = PageCache::new(8 << 20);
|
|
let worker = RenderWorker::spawn();
|
|
let generation = cache.begin_document();
|
|
let mut pending = PendingPages::new(generation);
|
|
|
|
assert_eq!(
|
|
pending.state(0, cache.contains(0)),
|
|
PageState::Absent,
|
|
"an unrequested page is absent"
|
|
);
|
|
|
|
let page = doc.page(0).expect("page 0");
|
|
pending.mark_requested(0);
|
|
worker.request(RenderRequest {
|
|
generation,
|
|
page_index: 0,
|
|
content: page.content_data.clone(),
|
|
page_height: page.height(),
|
|
color_spaces: page.color_spaces.clone(),
|
|
ext_gstates: page.ext_gstates.clone(),
|
|
});
|
|
assert_eq!(
|
|
pending.state(0, cache.contains(0)),
|
|
PageState::Loading,
|
|
"a requested page shows a placeholder"
|
|
);
|
|
|
|
let result = worker.recv_blocking().expect("a result");
|
|
cache.insert(result.generation, result.page_index, result.page);
|
|
pending.mark_done(0);
|
|
assert_eq!(
|
|
pending.state(0, cache.contains(0)),
|
|
PageState::Ready,
|
|
"once rendered, the real page is drawn"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scrolling_evicts_pages_that_left_the_viewport() {
|
|
let mut doc = PdfDocument::parse(LARGE).expect("parses");
|
|
let mut cache = PageCache::new(64 << 20);
|
|
let generation = cache.begin_document();
|
|
|
|
for index in 0..30 {
|
|
let Ok(page) = doc.page(index) else { continue };
|
|
let Ok(ops) = parse_content_stream(&page.content_data) else {
|
|
continue;
|
|
};
|
|
cache.insert(
|
|
generation,
|
|
index,
|
|
CachedPage {
|
|
commands: RecordingDevice::from_ops(&ops),
|
|
images: Default::default(),
|
|
page_height: page.height(),
|
|
appearance_dirty: false,
|
|
},
|
|
);
|
|
}
|
|
assert!(cache.len() >= 20);
|
|
|
|
// The user scrolls to pages 20..=22.
|
|
cache.retain_around(20, 22, 2);
|
|
assert!(cache.contains(20) && cache.contains(22));
|
|
assert!(cache.contains(18), "the margin is kept");
|
|
assert!(!cache.contains(5), "a distant page is released");
|
|
assert!(cache.len() <= 7, "window plus margin only");
|
|
}
|
|
|
|
#[test]
|
|
fn a_form_edit_does_not_discard_the_pages_parsed_commands() {
|
|
let mut doc = PdfDocument::parse(LARGE).expect("parses");
|
|
let mut cache = PageCache::new(8 << 20);
|
|
let generation = cache.begin_document();
|
|
|
|
let page = doc.page(0).expect("page 0");
|
|
let ops = parse_content_stream(&page.content_data).expect("parses");
|
|
let command_count = RecordingDevice::from_ops(&ops).len();
|
|
cache.insert(
|
|
generation,
|
|
0,
|
|
CachedPage {
|
|
commands: RecordingDevice::from_ops(&ops),
|
|
images: Default::default(),
|
|
page_height: page.height(),
|
|
appearance_dirty: false,
|
|
},
|
|
);
|
|
|
|
// A form edit invalidates the drawn appearance, not the content stream.
|
|
cache.invalidate_appearance(0);
|
|
assert_eq!(cache.dirty_pages(), vec![0]);
|
|
assert_eq!(
|
|
cache.peek(0).expect("still cached").commands.len(),
|
|
command_count,
|
|
"re-parsing the content stream after a form edit is wasted work"
|
|
);
|
|
}
|