Skip to content

Commit 6a94570

Browse files
committed
feat(pdf): hand the finished file across the wasm boundary in blocks
1 parent 0f3c256 commit 6a94570

18 files changed

Lines changed: 592 additions & 48 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@imprentajs/pdf": patch
3+
---
4+
5+
The finished PDF crosses the wasm boundary in blocks, never as one buffer.
6+
7+
The writer has always produced the file in blocks that are never moved; the
8+
join that made them contiguous for `imprenta_out_ptr` was the last place the
9+
engine held a document twice — and linear memory never shrinks, so that peak
10+
was the footprint (#7). The module now hands over exactly the blocks the
11+
writer produced, through `imprenta_out_blocks` / `imprenta_out_block_ptr` /
12+
`imprenta_out_block_len`, and the one contiguous copy is assembled on the JS
13+
heap, where memory goes back.
14+
15+
`renderToFile` no longer assembles at all: the worker writes block by block,
16+
so a long document exists whole nowhere but on disk — not in linear memory,
17+
not in the Node heap. That was the real reason to want this.
18+
19+
The output is byte for byte what it was; the equivalence tests still compare
20+
bytes and still pass. Measured on a 10,680-page ledger producing a 21.78 MB
21+
file: **68.0 → 47.2 MB** of linear memory. The saving is the size of the
22+
file, so it grows with the document.

‎crates/imprenta-pdf-wasm/src/job.rs‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,15 @@ pub struct ImageInput {
2727
}
2828

2929
/// What came of a render.
30+
///
31+
/// The file stays in the blocks the writer produced — see
32+
/// [`imprenta_pdf::Pdf`] — and the host reads them one at a time
33+
/// through `imprenta_out_block_ptr`. Joining them here would put a second
34+
/// copy of the document in linear memory, which never shrinks, at exactly the
35+
/// moment the first is at its largest.
3036
#[derive(Debug, Clone, PartialEq, Eq)]
3137
pub struct Outcome {
32-
pub pdf: Vec<u8>,
38+
pub pdf: imprenta_pdf::Pdf,
3339
pub pages: usize,
3440
pub diagnostics: Vec<String>,
3541
}

‎crates/imprenta-pdf-wasm/src/lib.rs‎

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
//! - Every call returns `1` for success and `0` for failure, and a failure
3030
//! leaves a message at [`imprenta_error_ptr`]. Nothing panics across the boundary:
3131
//! a malformed document is an error the host can print, not a dead instance.
32-
//! - The result is read with [`imprenta_out_ptr`]/[`imprenta_out_len`] and given back with
32+
//! - The result is read block by block with [`imprenta_out_blocks`]/[`imprenta_out_block_ptr`]/
33+
//! [`imprenta_out_block_len`] and given back with
3334
//! [`imprenta_out_release`], so a long-lived instance does not sit on the last PDF it
3435
//! produced. The pointer is only good until the next allocation grows the
3536
//! memory, which is why the JavaScript binding copies rather than handing a
@@ -56,7 +57,7 @@ thread_local! {
5657
};
5758
/// The finished document, kept until the host has read it or released it.
5859
static OUT: RefCell<Outcome> = const {
59-
RefCell::new(Outcome { pdf: Vec::new(), pages: 0, diagnostics: Vec::new() })
60+
RefCell::new(Outcome { pdf: imprenta_pdf::Pdf::empty(), pages: 0, diagnostics: Vec::new() })
6061
};
6162
/// The diagnostics, as the JSON array the host reads.
6263
static DIAGNOSTICS: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
@@ -218,7 +219,7 @@ pub unsafe extern "C" fn imprenta_assets_image(
218219

219220
// ── One document, declared whole ────────────────────────────────────────────
220221

221-
/// Renders a declared document. Read the result with [`imprenta_out_ptr`].
222+
/// Renders a declared document. Read the result with [`imprenta_out_block_ptr`].
222223
///
223224
/// # Safety
224225
///
@@ -251,7 +252,7 @@ pub unsafe extern "C" fn imprenta_render(ir_ptr: *const u8, ir_len: usize) -> i3
251252
// exists and why it packs rather than estimating.
252253

253254
/// Measures a run of table rows, keeps them, and hands back one height each
254-
/// as little-endian `f32` readable with [`imprenta_out_ptr`].
255+
/// as little-endian `f32` readable with [`imprenta_out_block_ptr`].
255256
///
256257
/// Only the heights cross: four bytes a row, against the kilobytes the row
257258
/// itself weighs. The rows stay here for [`imprenta_fragment_measured`], which
@@ -302,7 +303,8 @@ pub unsafe extern "C" fn imprenta_measure_rows(
302303
MEASURED.with(|slot| *slot.borrow_mut() = rows);
303304
OUT.with(|slot| {
304305
let mut held = slot.borrow_mut();
305-
held.pdf = out;
306+
// Already contiguous, so it crosses as one block.
307+
held.pdf = out.into();
306308
held.pages = count;
307309
held.diagnostics = Vec::new();
308310
});
@@ -445,7 +447,8 @@ pub unsafe extern "C" fn imprenta_plan(
445447
OUT.with(|slot| {
446448
let mut held = slot.borrow_mut();
447449
held.pages = json.len();
448-
held.pdf = bytes;
450+
// Already contiguous, so it crosses as one block.
451+
held.pdf = bytes.into();
449452
held.diagnostics = Vec::new();
450453
});
451454
succeed()
@@ -473,7 +476,7 @@ pub unsafe extern "C" fn imprenta_merge_push(ptr: *const u8, len: usize) -> i32
473476
succeed()
474477
}
475478

476-
/// Merges what was pushed and publishes the file at [`imprenta_out_ptr`].
479+
/// Merges what was pushed and publishes the file at [`imprenta_out_block_ptr`].
477480
#[unsafe(no_mangle)]
478481
pub extern "C" fn imprenta_merge_finish() -> i32 {
479482
let fragments = FRAGMENTS.with(|slot| std::mem::take(&mut *slot.borrow_mut()));
@@ -484,7 +487,7 @@ pub extern "C" fn imprenta_merge_finish() -> i32 {
484487
// caught here rather than by whoever opens the document.
485488
let pages = merge::pages_in(&pdf).unwrap_or(0);
486489
publish(Outcome {
487-
pdf,
490+
pdf: pdf.into(),
488491
pages,
489492
diagnostics: Vec::new(),
490493
})
@@ -563,7 +566,7 @@ pub extern "C" fn imprenta_stream_pending() -> usize {
563566
PRINTER.with(|slot| slot.borrow().as_ref().map_or(0, Printer::pending))
564567
}
565568

566-
/// Paints what is left and closes the document. Read it with [`imprenta_out_ptr`].
569+
/// Paints what is left and closes the document. Read it with [`imprenta_out_block_ptr`].
567570
#[unsafe(no_mangle)]
568571
pub extern "C" fn imprenta_stream_finish() -> i32 {
569572
let printer = PRINTER.with(|slot| slot.borrow_mut().take());
@@ -578,14 +581,35 @@ pub extern "C" fn imprenta_stream_finish() -> i32 {
578581

579582
// ── Reading the result ──────────────────────────────────────────────────────
580583

584+
/// How many pieces the result is in. Read them with
585+
/// [`imprenta_out_block_ptr`] and [`imprenta_out_block_len`]; concatenated in
586+
/// order they are the file.
587+
///
588+
/// Blocks rather than one buffer, deliberately: handing back a contiguous
589+
/// result meant joining the writer's blocks inside linear memory, which held
590+
/// two copies of the finished document at the peak — and linear memory never
591+
/// shrinks, so the peak was the footprint (issue #7). The host assembles the
592+
/// copy on its own heap, where the memory goes back.
593+
#[unsafe(no_mangle)]
594+
pub extern "C" fn imprenta_out_blocks() -> usize {
595+
OUT.with(|slot| slot.borrow().pdf.block_count())
596+
}
597+
598+
/// Where the `index`-th piece starts. Null past the end, never a trap.
581599
#[unsafe(no_mangle)]
582-
pub extern "C" fn imprenta_out_ptr() -> *const u8 {
583-
OUT.with(|slot| slot.borrow().pdf.as_ptr())
600+
pub extern "C" fn imprenta_out_block_ptr(index: usize) -> *const u8 {
601+
OUT.with(|slot| {
602+
slot.borrow()
603+
.pdf
604+
.block(index)
605+
.map_or(std::ptr::null(), <[u8]>::as_ptr)
606+
})
584607
}
585608

609+
/// How long the `index`-th piece is. Zero past the end.
586610
#[unsafe(no_mangle)]
587-
pub extern "C" fn imprenta_out_len() -> usize {
588-
OUT.with(|slot| slot.borrow().pdf.len())
611+
pub extern "C" fn imprenta_out_block_len(index: usize) -> usize {
612+
OUT.with(|slot| slot.borrow().pdf.block(index).map_or(0, <[u8]>::len))
589613
}
590614

591615
#[unsafe(no_mangle)]
@@ -625,7 +649,7 @@ pub extern "C" fn imprenta_error_len() -> usize {
625649
pub extern "C" fn imprenta_out_release() -> i32 {
626650
OUT.with(|slot| {
627651
let mut out = slot.borrow_mut();
628-
out.pdf = Vec::new();
652+
out.pdf = imprenta_pdf::Pdf::empty();
629653
out.pages = 0;
630654
out.diagnostics = Vec::new();
631655
});
@@ -669,8 +693,25 @@ mod tests {
669693
give_back(data);
670694
}
671695

696+
/// The result as the host assembles it: the blocks, in order.
672697
fn read_out() -> Vec<u8> {
673-
unsafe { std::slice::from_raw_parts(imprenta_out_ptr(), imprenta_out_len()) }.to_vec()
698+
let mut out = Vec::with_capacity(out_len());
699+
for index in 0..imprenta_out_blocks() {
700+
out.extend_from_slice(unsafe {
701+
std::slice::from_raw_parts(
702+
imprenta_out_block_ptr(index),
703+
imprenta_out_block_len(index),
704+
)
705+
});
706+
}
707+
out
708+
}
709+
710+
/// The total the blocks add up to, which is what `out_len` used to say.
711+
fn out_len() -> usize {
712+
(0..imprenta_out_blocks())
713+
.map(|index| imprenta_out_block_len(index))
714+
.sum()
674715
}
675716

676717
fn read_error() -> String {
@@ -815,17 +856,36 @@ mod tests {
815856
give_back(ir);
816857
}
817858

859+
#[test]
860+
fn a_block_index_past_the_end_is_null_and_zero_rather_than_a_trap() {
861+
// The index arrives over the boundary, where a panic kills the
862+
// instance. A host that miscounts must get an answer it can check.
863+
let _lock = guard();
864+
load_roman();
865+
let ir = put(HELLO);
866+
unsafe { imprenta_render(ir.0, ir.1) };
867+
let count = imprenta_out_blocks();
868+
869+
assert!(count >= 1);
870+
assert!(imprenta_out_block_ptr(count).is_null());
871+
assert_eq!(imprenta_out_block_len(count), 0);
872+
873+
imprenta_out_release();
874+
give_back(ir);
875+
}
876+
818877
#[test]
819878
fn releasing_the_result_gives_the_bytes_back() {
820879
let _lock = guard();
821880
load_roman();
822881
let ir = put(HELLO);
823882
unsafe { imprenta_render(ir.0, ir.1) };
824-
assert!(imprenta_out_len() > 0);
883+
assert!(out_len() > 0);
825884

826885
imprenta_out_release();
827886

828-
assert_eq!(imprenta_out_len(), 0);
887+
assert_eq!(out_len(), 0);
888+
assert_eq!(imprenta_out_blocks(), 0);
829889
assert_eq!(imprenta_out_pages(), 0);
830890
assert_eq!(imprenta_diagnostics_len(), 0);
831891
give_back(ir);

‎crates/imprenta-pdf-wasm/src/merge.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ mod tests {
186186
r#"{{"page":{{"width":595,"height":842}},"children":[{{"t":"table","columns":[{{"width":{{"unit":"percent","value":0.6}}}},{{"width":{{"unit":"percent","value":0.4}}}}],"rows":[{}]}}]}}"#,
187187
rows.join(",")
188188
);
189-
run(ir.as_bytes(), &roman()).unwrap().pdf
189+
run(ir.as_bytes(), &roman()).unwrap().pdf.into_vec()
190190
}
191191

192192
fn pages_of(pdf: &[u8]) -> usize {

‎crates/imprenta-pdf-write/src/blocks.rs‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,45 @@ const CEILING: usize = 4 * 1024 * 1024;
3030
/// The first block, and the smallest anything costs.
3131
const FLOOR: usize = 8 * 1024;
3232

33-
#[derive(Default)]
33+
#[derive(Default, Clone)]
3434
pub(crate) struct Blocks {
3535
blocks: Vec<Vec<u8>>,
3636
len: usize,
3737
}
3838

3939
impl Blocks {
40+
/// Nothing written. `const` so a thread-local on the far side of the wasm
41+
/// boundary can start with one without a lazy initialiser.
42+
pub const fn empty() -> Self {
43+
Self {
44+
blocks: Vec::new(),
45+
len: 0,
46+
}
47+
}
48+
4049
/// How many bytes have been written in all. This is the offset the next
4150
/// object will start at, which is what an xref entry records.
4251
pub fn len(&self) -> usize {
4352
self.len
4453
}
4554

55+
/// How many pieces the file is in.
56+
pub fn count(&self) -> usize {
57+
self.blocks.len()
58+
}
59+
60+
/// One piece, by position. `None` past the end rather than a panic,
61+
/// because the index arrives over the wasm boundary where a panic is a
62+
/// trap that takes the instance with it.
63+
pub fn get(&self, index: usize) -> Option<&[u8]> {
64+
self.blocks.get(index).map(Vec::as_slice)
65+
}
66+
67+
/// The pieces in file order. Concatenated, they are the file.
68+
pub fn iter(&self) -> impl Iterator<Item = &[u8]> {
69+
self.blocks.iter().map(Vec::as_slice)
70+
}
71+
4672
pub fn push(&mut self, bytes: &[u8]) {
4773
let mut rest = bytes;
4874
while !rest.is_empty() {

‎crates/imprenta-pdf-write/src/lib.rs‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,11 @@
4343
mod blocks;
4444
mod font;
4545
mod image;
46+
mod pdf;
4647

4748
use blocks::Blocks;
4849
use imprenta_core::color::Color;
50+
pub use pdf::Pdf;
4951
use pdf_writer::{Chunk, Content, Finish, Name, Rect, Ref, Str};
5052
use std::collections::HashMap;
5153
use std::sync::Arc;
@@ -225,7 +227,12 @@ impl Writer {
225227

226228
/// Writes the fonts, the images, the page tree and the catalogue, then
227229
/// the cross-reference table.
228-
pub fn finish(mut self) -> Result<Vec<u8>, WriteError> {
230+
///
231+
/// What comes back is the file still in its blocks — see [`Pdf`]. A
232+
/// caller that wants one buffer dereferences or calls
233+
/// [`Pdf::into_vec`]; the wasm boundary and a file sink take the blocks
234+
/// as they lie and never pay for the join.
235+
pub fn finish(mut self) -> Result<Pdf, WriteError> {
229236
if self.page_refs.is_empty() {
230237
return Err(WriteError::NoPages);
231238
}
@@ -304,7 +311,7 @@ impl Writer {
304311
///
305312
/// Written by hand because everything above it has been: the offsets are
306313
/// this writer's own, and a table entry is twenty bytes.
307-
fn trailer(mut self, catalog: Ref) -> Vec<u8> {
314+
fn trailer(mut self, catalog: Ref) -> Pdf {
308315
self.offsets.sort_unstable();
309316
let size = 1 + self.offsets.last().map_or(0, |(id, _)| *id);
310317
let start = self.blocks.len();
@@ -329,7 +336,10 @@ impl Writer {
329336
));
330337
self.blocks.push(table.as_bytes());
331338

332-
self.blocks.into_vec()
339+
// Not joined here, deliberately. This was the last place the engine
340+
// held a document twice, and inside a WebAssembly module that peak is
341+
// permanent; whoever needs the file contiguous pays for it there.
342+
Pdf::from_blocks(self.blocks)
333343
}
334344
}
335345

0 commit comments

Comments
 (0)