bare-x-to-pdf 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -1
- package/binding.c +7 -1
- package/core/include/simple_converter.h +8 -0
- package/core/src/error.rs +24 -7
- package/core/src/lib.rs +5 -1
- package/core/src/limits.rs +15 -1
- package/core/src/typst_backend/chunks.rs +275 -0
- package/core/src/typst_backend/emit.rs +129 -28
- package/core/src/typst_backend/fonts.rs +1 -0
- package/core/src/typst_backend/images.rs +17 -12
- package/core/src/typst_backend/merge.rs +286 -0
- package/core/src/typst_backend/mod.rs +27 -12
- package/core/src/xlsx/mod.rs +1 -1
- package/core/src/xlsx/ods.rs +1 -1
- package/core/src/xml.rs +13 -2
- package/package.json +12 -12
- package/prebuilds/wasi-wasm32/bare-x-to-pdf.wasm +0 -0
package/README.md
CHANGED
|
@@ -131,10 +131,14 @@ Documents are often untrusted, so the engine caps what a small file can make it
|
|
|
131
131
|
lay out. A document past a hard limit is rejected with an error. One past a soft limit still
|
|
132
132
|
converts, with the value clamped.
|
|
133
133
|
|
|
134
|
-
Rejected
|
|
134
|
+
Rejected, with `err.code` set to `'LIMIT'` so an app can tell these apart from a damaged
|
|
135
|
+
file:
|
|
135
136
|
|
|
136
137
|
- An archive part larger than 128 MB uncompressed.
|
|
137
138
|
- XML nested more than 512 elements deep.
|
|
139
|
+
- An XML part with more than 1,000,000 elements.
|
|
140
|
+
- A document whose content needs more than 64 MB of Typst source to lay out, about 8,000
|
|
141
|
+
pages of dense text.
|
|
138
142
|
- A spreadsheet that would print more than 30,000 cells.
|
|
139
143
|
|
|
140
144
|
Clamped:
|
|
@@ -152,6 +156,19 @@ Clamped:
|
|
|
152
156
|
|
|
153
157
|
Spreadsheet pages with no content are not printed, as in LibreOffice Calc.
|
|
154
158
|
|
|
159
|
+
## Long documents
|
|
160
|
+
|
|
161
|
+
Typst keeps every page in memory until a document is laid out, about 1 MB per page of dense
|
|
162
|
+
text. A document whose content needs more than 2 MB of Typst source, about 270 pages, is
|
|
163
|
+
laid out in chunks of that size instead, each ending where a page starts with a new
|
|
164
|
+
paragraph or table, and the chunks are joined into one PDF. Pages, page numbers, totals,
|
|
165
|
+
headers, footers and footnote numbers come out as in a single pass, and memory stays near
|
|
166
|
+
what one chunk needs: a 5,900-page document peaks at about 510 MB instead of 6 GB.
|
|
167
|
+
|
|
168
|
+
A chunked PDF is not tagged, since the tags of separate chunks cannot be joined, so it
|
|
169
|
+
carries no structure for screen readers. Presentations with speaker notes are always laid
|
|
170
|
+
out in a single pass.
|
|
171
|
+
|
|
155
172
|
## Development
|
|
156
173
|
|
|
157
174
|
The platform packages in `npm/` are npm workspaces, and each declares its own `os` and `cpu`
|
package/binding.c
CHANGED
|
@@ -196,8 +196,14 @@ simple_converter__error(js_env_t *env, simple_converter_job_t *job) {
|
|
|
196
196
|
}
|
|
197
197
|
assert(err == 0);
|
|
198
198
|
|
|
199
|
+
js_value_t *code = NULL;
|
|
200
|
+
if (job->status == SC_STATUS_LIMIT) {
|
|
201
|
+
err = js_create_string_utf8(env, (utf8_t *) "LIMIT", (size_t) -1, &code);
|
|
202
|
+
assert(err == 0);
|
|
203
|
+
}
|
|
204
|
+
|
|
199
205
|
js_value_t *error;
|
|
200
|
-
err = js_create_error(env,
|
|
206
|
+
err = js_create_error(env, code, message, &error);
|
|
201
207
|
assert(err == 0);
|
|
202
208
|
|
|
203
209
|
return error;
|
package/core/src/error.rs
CHANGED
|
@@ -1,21 +1,38 @@
|
|
|
1
1
|
use std::fmt;
|
|
2
2
|
|
|
3
3
|
#[derive(Debug, Clone)]
|
|
4
|
-
pub struct Error
|
|
4
|
+
pub struct Error {
|
|
5
|
+
message: String,
|
|
6
|
+
limit: bool,
|
|
7
|
+
}
|
|
5
8
|
|
|
6
9
|
impl Error {
|
|
7
10
|
pub fn new(message: impl Into<String>) -> Self {
|
|
8
|
-
Error
|
|
11
|
+
Error {
|
|
12
|
+
message: message.into(),
|
|
13
|
+
limit: false,
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
pub fn limit(message: impl Into<String>) -> Self {
|
|
18
|
+
Error {
|
|
19
|
+
message: message.into(),
|
|
20
|
+
limit: true,
|
|
21
|
+
}
|
|
9
22
|
}
|
|
10
23
|
|
|
11
24
|
pub fn message(&self) -> &str {
|
|
12
|
-
&self.
|
|
25
|
+
&self.message
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
pub fn is_limit(&self) -> bool {
|
|
29
|
+
self.limit
|
|
13
30
|
}
|
|
14
31
|
}
|
|
15
32
|
|
|
16
33
|
impl fmt::Display for Error {
|
|
17
34
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
18
|
-
f.write_str(&self.
|
|
35
|
+
f.write_str(&self.message)
|
|
19
36
|
}
|
|
20
37
|
}
|
|
21
38
|
|
|
@@ -23,18 +40,18 @@ impl std::error::Error for Error {}
|
|
|
23
40
|
|
|
24
41
|
impl From<std::io::Error> for Error {
|
|
25
42
|
fn from(e: std::io::Error) -> Self {
|
|
26
|
-
Error(format!("io: {e}"))
|
|
43
|
+
Error::new(format!("io: {e}"))
|
|
27
44
|
}
|
|
28
45
|
}
|
|
29
46
|
|
|
30
47
|
impl From<zip::result::ZipError> for Error {
|
|
31
48
|
fn from(e: zip::result::ZipError) -> Self {
|
|
32
|
-
Error(format!("zip: {e}"))
|
|
49
|
+
Error::new(format!("zip: {e}"))
|
|
33
50
|
}
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
impl From<quick_xml::Error> for Error {
|
|
37
54
|
fn from(e: quick_xml::Error) -> Self {
|
|
38
|
-
Error(format!("xml: {e}"))
|
|
55
|
+
Error::new(format!("xml: {e}"))
|
|
39
56
|
}
|
|
40
57
|
}
|
package/core/src/lib.rs
CHANGED
package/core/src/limits.rs
CHANGED
|
@@ -2,6 +2,7 @@ use crate::error::Error;
|
|
|
2
2
|
use std::io::Read;
|
|
3
3
|
|
|
4
4
|
pub const MAX_XML_DEPTH: usize = 512;
|
|
5
|
+
pub const MAX_XML_ELEMENTS: usize = 1_000_000;
|
|
5
6
|
pub const MAX_FORMULA_DEPTH: usize = 256;
|
|
6
7
|
pub const MAX_ZIP_ENTRY_BYTES: u64 = 128 * 1024 * 1024;
|
|
7
8
|
pub const MAX_TABLE_SPAN: usize = 1000;
|
|
@@ -19,6 +20,19 @@ pub const MAX_SHEET_CELLS: u64 = 30_000;
|
|
|
19
20
|
pub const MIN_PAGE_SIDE: f64 = 72.0;
|
|
20
21
|
pub const MAX_PAGE_SIDE: f64 = 14_400.0;
|
|
21
22
|
pub const MAX_LENGTH: f64 = 1_000_000.0;
|
|
23
|
+
pub const MAX_LAYOUT_SOURCE_BYTES: usize = 64 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
pub fn check_layout_source(bytes: usize) -> Result<(), Error> {
|
|
26
|
+
if bytes > MAX_LAYOUT_SOURCE_BYTES {
|
|
27
|
+
let mb = |n: usize| n.div_ceil(1024 * 1024);
|
|
28
|
+
return Err(Error::limit(format!(
|
|
29
|
+
"document is too large to convert: its content needs {} MB of layout source, more than the {} MB limit",
|
|
30
|
+
mb(bytes),
|
|
31
|
+
mb(MAX_LAYOUT_SOURCE_BYTES)
|
|
32
|
+
)));
|
|
33
|
+
}
|
|
34
|
+
Ok(())
|
|
35
|
+
}
|
|
22
36
|
|
|
23
37
|
pub fn span(value: usize) -> usize {
|
|
24
38
|
value.clamp(1, MAX_TABLE_SPAN)
|
|
@@ -36,7 +50,7 @@ pub fn read_entry(reader: impl Read) -> Result<Vec<u8>, Error> {
|
|
|
36
50
|
let mut data = Vec::new();
|
|
37
51
|
reader.take(MAX_ZIP_ENTRY_BYTES + 1).read_to_end(&mut data)?;
|
|
38
52
|
if data.len() as u64 > MAX_ZIP_ENTRY_BYTES {
|
|
39
|
-
return Err(Error::
|
|
53
|
+
return Err(Error::limit(format!("a part of the document is larger than {} MB uncompressed", MAX_ZIP_ENTRY_BYTES / (1024 * 1024))));
|
|
40
54
|
}
|
|
41
55
|
Ok(data)
|
|
42
56
|
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
use super::emit::{self, Chunk};
|
|
2
|
+
use super::fonts::FontSet;
|
|
3
|
+
use super::{export, layout, merge};
|
|
4
|
+
use crate::error::Error;
|
|
5
|
+
use crate::model::Document;
|
|
6
|
+
use std::num::NonZeroUsize;
|
|
7
|
+
use std::ops::Range;
|
|
8
|
+
use typst::foundations::{Label, NativeElement, Selector, Value};
|
|
9
|
+
use typst::introspection::{Introspector, MetadataElem};
|
|
10
|
+
use typst::layout::PageRanges;
|
|
11
|
+
use typst::model::FootnoteElem;
|
|
12
|
+
use typst::utils::PicoStr;
|
|
13
|
+
use typst_layout::PagedDocument;
|
|
14
|
+
use typst_pdf::PdfOptions;
|
|
15
|
+
|
|
16
|
+
pub const CHUNK_SOURCE_BYTES: usize = 2 * 1024 * 1024;
|
|
17
|
+
const TOP_TOLERANCE: f64 = 0.5;
|
|
18
|
+
|
|
19
|
+
type Cursor = (usize, usize);
|
|
20
|
+
|
|
21
|
+
type Sink<'a> = dyn FnMut(&PagedDocument, usize) -> Result<(), Error> + 'a;
|
|
22
|
+
|
|
23
|
+
pub fn render(doc: &Document, fonts: &FontSet, sizes: &[Vec<usize>], uses_total_pages: bool) -> Result<Vec<u8>, Error> {
|
|
24
|
+
render_in_chunks(doc, fonts, sizes, uses_total_pages, CHUNK_SOURCE_BYTES)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fn render_in_chunks(doc: &Document, fonts: &FontSet, sizes: &[Vec<usize>], uses_total_pages: bool, budget: usize) -> Result<Vec<u8>, Error> {
|
|
28
|
+
let mut pdfs = Vec::new();
|
|
29
|
+
paginate(doc, fonts, sizes, uses_total_pages, budget, &mut |paged, keep| {
|
|
30
|
+
let options = PdfOptions {
|
|
31
|
+
tagged: false,
|
|
32
|
+
page_ranges: Some(PageRanges::new(vec![NonZeroUsize::new(1)..=NonZeroUsize::new(keep)])),
|
|
33
|
+
..PdfOptions::default()
|
|
34
|
+
};
|
|
35
|
+
pdfs.push(export(paged, &options)?);
|
|
36
|
+
Ok(())
|
|
37
|
+
})?;
|
|
38
|
+
merge::merge(&pdfs)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fn paginate(doc: &Document, fonts: &FontSet, sizes: &[Vec<usize>], uses_total_pages: bool, budget: usize, sink: &mut Sink) -> Result<(), Error> {
|
|
42
|
+
let total_pages = if uses_total_pages {
|
|
43
|
+
Some(run(doc, fonts, sizes, None, budget, &mut |_, _| Ok(()))?)
|
|
44
|
+
} else {
|
|
45
|
+
None
|
|
46
|
+
};
|
|
47
|
+
run(doc, fonts, sizes, total_pages, budget, sink)?;
|
|
48
|
+
Ok(())
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fn run(doc: &Document, fonts: &FontSet, sizes: &[Vec<usize>], total_pages: Option<u64>, budget: usize, sink: &mut Sink) -> Result<u64, Error> {
|
|
52
|
+
let mut last_number = 0;
|
|
53
|
+
let mut chunk = Chunk {
|
|
54
|
+
total_pages,
|
|
55
|
+
..Chunk::default()
|
|
56
|
+
};
|
|
57
|
+
let mut cursor: Cursor = (0, 0);
|
|
58
|
+
while cursor.0 < sizes.len() {
|
|
59
|
+
let mut span = budget;
|
|
60
|
+
let (paged, keep, resume) = loop {
|
|
61
|
+
let (parts, next) = take(sizes, cursor, span);
|
|
62
|
+
chunk.parts = parts;
|
|
63
|
+
let paged = layout(emit::emit_chunk(doc, fonts, &chunk), fonts.clone())?;
|
|
64
|
+
let count = paged.pages().len();
|
|
65
|
+
if next.0 >= sizes.len() {
|
|
66
|
+
break (paged, count, next);
|
|
67
|
+
}
|
|
68
|
+
if let Some((keep, resume)) = find_cut(&paged, doc, cursor) {
|
|
69
|
+
break (paged, keep, resume);
|
|
70
|
+
}
|
|
71
|
+
drop(paged);
|
|
72
|
+
typst::comemo::evict(0);
|
|
73
|
+
span = span.saturating_mul(2);
|
|
74
|
+
};
|
|
75
|
+
if keep > 0 {
|
|
76
|
+
sink(&paged, keep)?;
|
|
77
|
+
last_number = paged.pages()[keep - 1].number;
|
|
78
|
+
chunk.page_number = Some(last_number + 1);
|
|
79
|
+
}
|
|
80
|
+
chunk.footnotes += footnotes_on(&paged, keep);
|
|
81
|
+
chunk.page_offset += keep;
|
|
82
|
+
chunk.resumed = true;
|
|
83
|
+
drop(paged);
|
|
84
|
+
typst::comemo::evict(0);
|
|
85
|
+
cursor = resume;
|
|
86
|
+
}
|
|
87
|
+
Ok(last_number)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fn take(sizes: &[Vec<usize>], cursor: Cursor, budget: usize) -> (Vec<(usize, Range<usize>)>, Cursor) {
|
|
91
|
+
let (mut section, mut block) = cursor;
|
|
92
|
+
let mut parts = Vec::new();
|
|
93
|
+
let mut used = 0;
|
|
94
|
+
while section < sizes.len() {
|
|
95
|
+
let start = block;
|
|
96
|
+
let blocks = &sizes[section];
|
|
97
|
+
while block < blocks.len() && (used < budget || (parts.is_empty() && block == start)) {
|
|
98
|
+
used += blocks[block];
|
|
99
|
+
block += 1;
|
|
100
|
+
}
|
|
101
|
+
parts.push((section, start..block));
|
|
102
|
+
if block < blocks.len() {
|
|
103
|
+
return (parts, (section, block));
|
|
104
|
+
}
|
|
105
|
+
section += 1;
|
|
106
|
+
block = 0;
|
|
107
|
+
if used >= budget {
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
(parts, (section, 0))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fn find_cut(paged: &PagedDocument, doc: &Document, start: Cursor) -> Option<(usize, Cursor)> {
|
|
115
|
+
let label = Label::new(PicoStr::intern(emit::CUT_LABEL))?;
|
|
116
|
+
let introspector = paged.introspector();
|
|
117
|
+
let mut best: Option<(usize, Cursor)> = None;
|
|
118
|
+
for content in introspector.query(&Selector::Label(label)) {
|
|
119
|
+
let Some(meta) = content.to_packed::<MetadataElem>() else {
|
|
120
|
+
continue;
|
|
121
|
+
};
|
|
122
|
+
let Value::Int(id) = meta.value else {
|
|
123
|
+
continue;
|
|
124
|
+
};
|
|
125
|
+
let at = emit::cut_position(id);
|
|
126
|
+
if at <= start {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
let Some(position) = content.location().and_then(|l| introspector.position(l)) else {
|
|
130
|
+
continue;
|
|
131
|
+
};
|
|
132
|
+
let page = position.page.get();
|
|
133
|
+
let Some(section) = doc.sections.get(at.0) else {
|
|
134
|
+
continue;
|
|
135
|
+
};
|
|
136
|
+
let scaled = section.content_scale > 0.0 && (section.content_scale - 1.0).abs() > 0.001;
|
|
137
|
+
if page < 2 || section.columns > 1 || scaled {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (position.point.y.to_pt() - section.page.margin.top).abs() > TOP_TOLERANCE {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
let better = match best {
|
|
144
|
+
None => true,
|
|
145
|
+
Some((best_page, best_at)) => page > best_page || (page == best_page && at < best_at),
|
|
146
|
+
};
|
|
147
|
+
if better {
|
|
148
|
+
best = Some((page, at));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
best.map(|(page, at)| (page - 1, at))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
fn footnotes_on(paged: &PagedDocument, pages: usize) -> usize {
|
|
155
|
+
let introspector = paged.introspector();
|
|
156
|
+
introspector
|
|
157
|
+
.query(&Selector::Elem(FootnoteElem::ELEM, None))
|
|
158
|
+
.iter()
|
|
159
|
+
.filter(|content| {
|
|
160
|
+
content
|
|
161
|
+
.location()
|
|
162
|
+
.and_then(|l| introspector.position(l))
|
|
163
|
+
.is_some_and(|p| p.page.get() <= pages)
|
|
164
|
+
})
|
|
165
|
+
.count()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#[cfg(test)]
|
|
169
|
+
mod tests {
|
|
170
|
+
use super::*;
|
|
171
|
+
use std::fmt::Write;
|
|
172
|
+
use std::path::Path;
|
|
173
|
+
use typst::layout::{Frame, FrameItem};
|
|
174
|
+
|
|
175
|
+
const BUDGET: usize = 16 * 1024;
|
|
176
|
+
|
|
177
|
+
fn fixture(name: &str) -> (Document, FontSet) {
|
|
178
|
+
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
|
179
|
+
let bytes = std::fs::read(root.join("test/fixtures").join(name)).unwrap();
|
|
180
|
+
let mut doc = crate::docx::read(&bytes).unwrap();
|
|
181
|
+
super::super::sanitize_pages(&mut doc);
|
|
182
|
+
(doc, FontSet::load(&root.join("fonts")).unwrap())
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
fn page_text(page: &typst_layout::Page) -> String {
|
|
186
|
+
fn walk(frame: &Frame, out: &mut String) {
|
|
187
|
+
for (point, item) in frame.items() {
|
|
188
|
+
match item {
|
|
189
|
+
FrameItem::Group(group) => walk(&group.frame, out),
|
|
190
|
+
FrameItem::Text(text) => {
|
|
191
|
+
let _ = write!(out, "{:.2},{:.2} {}|", point.x.to_pt(), point.y.to_pt(), text.text);
|
|
192
|
+
}
|
|
193
|
+
_ => {}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
let mut out = format!("#{} ", page.number);
|
|
198
|
+
walk(&page.frame, &mut out);
|
|
199
|
+
out
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
fn chunked_pages(name: &str) -> (Vec<String>, Vec<Vec<String>>) {
|
|
203
|
+
let (doc, fonts) = fixture(name);
|
|
204
|
+
let full = emit::emit(&doc, &fonts);
|
|
205
|
+
let sizes = full.block_sizes.clone();
|
|
206
|
+
let uses_total_pages = full.uses_total_pages;
|
|
207
|
+
let single = layout(full, fonts.clone()).unwrap().pages().iter().map(page_text).collect();
|
|
208
|
+
let mut chunks = Vec::new();
|
|
209
|
+
paginate(&doc, &fonts, &sizes, uses_total_pages, BUDGET, &mut |paged, keep| {
|
|
210
|
+
chunks.push(paged.pages()[..keep].iter().map(page_text).collect());
|
|
211
|
+
Ok(())
|
|
212
|
+
})
|
|
213
|
+
.unwrap();
|
|
214
|
+
(single, chunks)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
fn assert_same_pages(single: &[String], chunks: &[Vec<String>]) {
|
|
218
|
+
let chunked: Vec<&String> = chunks.iter().flatten().collect();
|
|
219
|
+
assert_eq!(chunked.len(), single.len());
|
|
220
|
+
for (index, (a, b)) in chunked.iter().zip(single).enumerate() {
|
|
221
|
+
assert_eq!(*a, b, "page {}", index + 1);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#[test]
|
|
226
|
+
fn chunked_layout_matches_a_single_pass() {
|
|
227
|
+
let (doc, fonts) = fixture("chunked-sections.docx");
|
|
228
|
+
assert!(emit::emit(&doc, &fonts).uses_total_pages);
|
|
229
|
+
let (single, chunks) = chunked_pages("chunked-sections.docx");
|
|
230
|
+
assert!(chunks.len() >= 8, "only {} chunks", chunks.len());
|
|
231
|
+
assert_same_pages(&single, &chunks);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#[test]
|
|
235
|
+
fn chunks_never_end_inside_columns_or_a_table() {
|
|
236
|
+
let (single, chunks) = chunked_pages("chunked-no-cut.docx");
|
|
237
|
+
assert!(chunks.len() >= 3, "only {} chunks", chunks.len());
|
|
238
|
+
assert_same_pages(&single, &chunks);
|
|
239
|
+
let block_starts = ["Opening paragraph", "End of", "Columns paragraph 0:", "row 0 cell 0", "Closing paragraph"];
|
|
240
|
+
for chunk in &chunks[1..] {
|
|
241
|
+
let first = chunk[0].split_once('|').map_or("", |(text, _)| text);
|
|
242
|
+
assert!(block_starts.iter().any(|start| first.contains(start)), "a chunk starts inside a block: {first}");
|
|
243
|
+
}
|
|
244
|
+
let longest = chunks.iter().map(Vec::len).max().unwrap();
|
|
245
|
+
let table_pages = single.iter().filter(|page| page.contains("row ")).count();
|
|
246
|
+
assert!(longest >= table_pages, "the table was split across chunks");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#[test]
|
|
250
|
+
fn merged_pdf_is_well_formed() {
|
|
251
|
+
for name in ["chunked-sections.docx", "chunked-no-cut.docx"] {
|
|
252
|
+
let (doc, fonts) = fixture(name);
|
|
253
|
+
let full = emit::emit(&doc, &fonts);
|
|
254
|
+
let pages = layout(emit::emit(&doc, &fonts), fonts.clone()).unwrap().pages().len();
|
|
255
|
+
let pdf = render_in_chunks(&doc, &fonts, &full.block_sizes, full.uses_total_pages, BUDGET).unwrap();
|
|
256
|
+
assert_eq!(merge::check(&pdf).unwrap(), pages, "{name}");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
#[test]
|
|
261
|
+
fn typst_pdfs_have_the_structure_merge_expects() {
|
|
262
|
+
let (doc, fonts) = fixture("chunked-sections.docx");
|
|
263
|
+
let paged = layout(emit::emit(&doc, &fonts), fonts).unwrap();
|
|
264
|
+
for tagged in [true, false] {
|
|
265
|
+
let options = PdfOptions {
|
|
266
|
+
tagged,
|
|
267
|
+
..PdfOptions::default()
|
|
268
|
+
};
|
|
269
|
+
let pdf = export(&paged, &options).unwrap();
|
|
270
|
+
assert_eq!(merge::check(&pdf).unwrap(), paged.pages().len());
|
|
271
|
+
let doubled = merge::merge(&[pdf.clone(), pdf]).unwrap();
|
|
272
|
+
assert_eq!(merge::check(&doubled).unwrap(), 2 * paged.pages().len());
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -9,12 +9,43 @@ const DEFAULT_SIZE: f64 = 11.0;
|
|
|
9
9
|
const DEFAULT_CELL_MARGIN_X: f64 = 5.4;
|
|
10
10
|
const TEXT_SHIFT: f64 = 0.1;
|
|
11
11
|
|
|
12
|
+
pub const CUT_LABEL: &str = "sc-cut";
|
|
13
|
+
|
|
12
14
|
pub struct Emitted {
|
|
13
15
|
pub source: String,
|
|
14
16
|
pub files: Vec<(String, Vec<u8>)>,
|
|
17
|
+
pub block_sizes: Vec<Vec<usize>>,
|
|
18
|
+
pub uses_total_pages: bool,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#[derive(Debug, Clone, Default)]
|
|
22
|
+
pub struct Chunk {
|
|
23
|
+
pub parts: Vec<(usize, std::ops::Range<usize>)>,
|
|
24
|
+
pub page_offset: usize,
|
|
25
|
+
pub page_number: Option<u64>,
|
|
26
|
+
pub footnotes: usize,
|
|
27
|
+
pub total_pages: Option<u64>,
|
|
28
|
+
pub resumed: bool,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
pub fn cut_id(section: usize, block: usize) -> i64 {
|
|
32
|
+
((section as i64) << 32) | block as i64
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
pub fn cut_position(id: i64) -> (usize, usize) {
|
|
36
|
+
((id >> 32) as usize, (id & 0xffff_ffff) as usize)
|
|
15
37
|
}
|
|
16
38
|
|
|
17
39
|
pub fn emit(doc: &Document, fonts: &FontSet) -> Emitted {
|
|
40
|
+
let parts: Vec<(usize, std::ops::Range<usize>)> = doc.sections.iter().enumerate().map(|(i, s)| (i, 0..s.blocks.len())).collect();
|
|
41
|
+
run(doc, fonts, &parts, None)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
pub fn emit_chunk(doc: &Document, fonts: &FontSet, chunk: &Chunk) -> Emitted {
|
|
45
|
+
run(doc, fonts, &chunk.parts, Some(chunk))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
fn run(doc: &Document, fonts: &FontSet, parts: &[(usize, std::ops::Range<usize>)], chunk: Option<&Chunk>) -> Emitted {
|
|
18
49
|
let mut emitter = Emitter {
|
|
19
50
|
doc,
|
|
20
51
|
fonts,
|
|
@@ -23,14 +54,23 @@ pub fn emit(doc: &Document, fonts: &FontSet) -> Emitted {
|
|
|
23
54
|
page: RefCell::new(PageSetup::default()),
|
|
24
55
|
para_line: Cell::new(None),
|
|
25
56
|
table_depth: Cell::new(0),
|
|
57
|
+
chunk,
|
|
58
|
+
block_sizes: RefCell::new(Vec::new()),
|
|
59
|
+
uses_total_pages: Cell::new(false),
|
|
60
|
+
drop_leading_gap: Cell::new(chunk.is_some_and(|c| c.resumed)),
|
|
61
|
+
multi_column: false,
|
|
26
62
|
};
|
|
27
63
|
emitter.preamble();
|
|
28
|
-
for (index,
|
|
29
|
-
|
|
64
|
+
for (position, (index, range)) in parts.iter().enumerate() {
|
|
65
|
+
if let Some(section) = doc.sections.get(*index) {
|
|
66
|
+
emitter.section(*index, section, range, position == 0);
|
|
67
|
+
}
|
|
30
68
|
}
|
|
31
69
|
Emitted {
|
|
32
70
|
source: emitter.out,
|
|
33
71
|
files: emitter.files.into_inner(),
|
|
72
|
+
block_sizes: emitter.block_sizes.into_inner(),
|
|
73
|
+
uses_total_pages: emitter.uses_total_pages.get(),
|
|
34
74
|
}
|
|
35
75
|
}
|
|
36
76
|
|
|
@@ -42,6 +82,11 @@ struct Emitter<'a> {
|
|
|
42
82
|
page: RefCell<PageSetup>,
|
|
43
83
|
para_line: Cell<Option<f64>>,
|
|
44
84
|
table_depth: Cell<u32>,
|
|
85
|
+
chunk: Option<&'a Chunk>,
|
|
86
|
+
block_sizes: RefCell<Vec<Vec<usize>>>,
|
|
87
|
+
uses_total_pages: Cell<bool>,
|
|
88
|
+
drop_leading_gap: Cell<bool>,
|
|
89
|
+
multi_column: bool,
|
|
45
90
|
}
|
|
46
91
|
|
|
47
92
|
struct CellOut {
|
|
@@ -59,7 +104,7 @@ impl Emitter<'_> {
|
|
|
59
104
|
|
|
60
105
|
let _ = writeln!(
|
|
61
106
|
self.out,
|
|
62
|
-
"#set text(font: {}, size: {}, top-edge: \"ascender\", bottom-edge: \"descender\", hyphenate: false, fallback: true, kerning:
|
|
107
|
+
"#set text(font: {}, size: {}, top-edge: \"ascender\", bottom-edge: \"descender\", hyphenate: false, fallback: true, kerning: true, lang: \"en\")",
|
|
63
108
|
typst_str(&family),
|
|
64
109
|
pt(size)
|
|
65
110
|
);
|
|
@@ -79,18 +124,20 @@ impl Emitter<'_> {
|
|
|
79
124
|
);
|
|
80
125
|
}
|
|
81
126
|
|
|
82
|
-
fn section(&mut self, index: usize, section: &Section) {
|
|
127
|
+
fn section(&mut self, index: usize, section: &Section, range: &std::ops::Range<usize>, first_in_chunk: bool) {
|
|
83
128
|
*self.page.borrow_mut() = section.page.clone();
|
|
84
129
|
let page = §ion.page;
|
|
130
|
+
let continued = range.start > 0;
|
|
85
131
|
|
|
86
|
-
let header = self.margin_slot(section, true, index);
|
|
87
|
-
let footer = self.margin_slot(section, false, index);
|
|
132
|
+
let header = self.margin_slot(section, true, index, continued);
|
|
133
|
+
let footer = self.margin_slot(section, false, index, continued);
|
|
88
134
|
let section_columns = section.columns.min(crate::limits::MAX_TEXT_COLUMNS);
|
|
89
|
-
let columns = if section_columns > 1 {
|
|
135
|
+
let columns = if section_columns > 1 || self.multi_column {
|
|
90
136
|
format!(", columns: {section_columns}")
|
|
91
137
|
} else {
|
|
92
138
|
String::new()
|
|
93
139
|
};
|
|
140
|
+
self.multi_column = section_columns > 1;
|
|
94
141
|
let _ = writeln!(
|
|
95
142
|
self.out,
|
|
96
143
|
"#set page(width: {}, height: {}, margin: (top: {}, bottom: {}, left: {}, right: {}){columns}, header: {header}, footer: {footer}, header-ascent: 0pt, footer-descent: 0pt)",
|
|
@@ -104,17 +151,27 @@ impl Emitter<'_> {
|
|
|
104
151
|
if section_columns > 1 {
|
|
105
152
|
let _ = writeln!(self.out, "#set columns(gutter: {})", pt(section.column_gap));
|
|
106
153
|
}
|
|
107
|
-
if let Some(
|
|
154
|
+
if let Some(chunk) = self.chunk.filter(|_| first_in_chunk) {
|
|
155
|
+
if let Some(number) = chunk.page_number {
|
|
156
|
+
let _ = writeln!(self.out, "#counter(page).update({number})");
|
|
157
|
+
}
|
|
158
|
+
if chunk.footnotes > 0 {
|
|
159
|
+
let _ = writeln!(self.out, "#counter(footnote).update({})", chunk.footnotes);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if let Some(start) = section.page_start.filter(|_| !continued) {
|
|
108
163
|
let _ = writeln!(self.out, "#counter(page).update({})", start.clamp(0, i64::from(u32::MAX)));
|
|
109
164
|
}
|
|
110
165
|
let _ = writeln!(self.out, "#metadata(none) <section-{index}>");
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
166
|
+
if !continued {
|
|
167
|
+
for anchor in §ion.anchors {
|
|
168
|
+
let mut placed = String::new();
|
|
169
|
+
self.anchor(anchor, &mut placed);
|
|
170
|
+
self.out.push_str(&placed);
|
|
171
|
+
}
|
|
115
172
|
}
|
|
116
173
|
|
|
117
|
-
let blocks = self.
|
|
174
|
+
let blocks = self.blocks_in(§ion.blocks, false, Some((index, range)));
|
|
118
175
|
if section.content_scale > 0.0 && (section.content_scale - 1.0).abs() > 0.001 {
|
|
119
176
|
let percent = trim_num(section.content_scale * 100.0);
|
|
120
177
|
let _ = writeln!(self.out, "#scale(x: {percent}%, y: {percent}%, reflow: true)[{blocks}]");
|
|
@@ -123,14 +180,14 @@ impl Emitter<'_> {
|
|
|
123
180
|
}
|
|
124
181
|
}
|
|
125
182
|
|
|
126
|
-
fn margin_slot(&self, section: &Section, header: bool, index: usize) -> String {
|
|
183
|
+
fn margin_slot(&self, section: &Section, header: bool, index: usize, continued: bool) -> String {
|
|
127
184
|
let (default, first, even) = if header {
|
|
128
185
|
(§ion.header_default, §ion.header_first, §ion.header_even)
|
|
129
186
|
} else {
|
|
130
187
|
(§ion.footer_default, §ion.footer_first, §ion.footer_even)
|
|
131
188
|
};
|
|
132
189
|
let empty: Vec<Block> = Vec::new();
|
|
133
|
-
let first = if section.title_page { Some(first.as_ref().unwrap_or(&empty)) } else { None };
|
|
190
|
+
let first = if section.title_page && !continued { Some(first.as_ref().unwrap_or(&empty)) } else { None };
|
|
134
191
|
let even = if self.doc.even_odd_headers { Some(even.as_ref().unwrap_or(&empty)) } else { None };
|
|
135
192
|
if default.is_none() && first.is_none() && even.is_none() {
|
|
136
193
|
return "none".to_string();
|
|
@@ -159,7 +216,11 @@ impl Emitter<'_> {
|
|
|
159
216
|
let even_expr = even.map(|e| render(Some(e)));
|
|
160
217
|
|
|
161
218
|
let mut body = String::new();
|
|
162
|
-
let
|
|
219
|
+
let offset = match self.chunk.map_or(0, |c| c.page_offset) {
|
|
220
|
+
0 => String::new(),
|
|
221
|
+
pages => format!(" + {pages}"),
|
|
222
|
+
};
|
|
223
|
+
let _ = write!(body, "context {{ let n = here().page(){offset}; let first = locate(<section-{index}>).page(){offset}; ");
|
|
163
224
|
if let Some(first_expr) = first_expr {
|
|
164
225
|
let _ = write!(body, "if n == first {{ {first_expr} }} else ");
|
|
165
226
|
}
|
|
@@ -171,6 +232,13 @@ impl Emitter<'_> {
|
|
|
171
232
|
}
|
|
172
233
|
|
|
173
234
|
fn blocks(&self, blocks: &[Block], trailing: bool) -> String {
|
|
235
|
+
self.blocks_in(blocks, trailing, None)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
fn blocks_in(&self, blocks: &[Block], trailing: bool, top: Option<(usize, &std::ops::Range<usize>)>) -> String {
|
|
239
|
+
if top.is_some() && self.chunk.is_none() {
|
|
240
|
+
self.block_sizes.borrow_mut().push(Vec::with_capacity(blocks.len()));
|
|
241
|
+
}
|
|
174
242
|
let mut out = String::new();
|
|
175
243
|
let mut prev_after = 0.0;
|
|
176
244
|
let mut prev_style: Option<&str> = None;
|
|
@@ -200,48 +268,74 @@ impl Emitter<'_> {
|
|
|
200
268
|
.collect();
|
|
201
269
|
|
|
202
270
|
for (index, block) in blocks.iter().enumerate() {
|
|
271
|
+
let emit = top.is_none_or(|(_, range)| range.contains(&index));
|
|
272
|
+
let leading = emit && top.is_some() && self.drop_leading_gap.replace(false);
|
|
273
|
+
let start = out.len();
|
|
274
|
+
let first_in_group = match block {
|
|
275
|
+
Block::Paragraph(_) => keys[index].is_none() || index == 0 || keys[index - 1] != keys[index],
|
|
276
|
+
_ => true,
|
|
277
|
+
};
|
|
278
|
+
let breaks_first = match block {
|
|
279
|
+
Block::Paragraph(p) => p.props.page_break_before == Some(true) || matches!(p.inlines.first(), Some(Inline::PageBreak)),
|
|
280
|
+
_ => false,
|
|
281
|
+
};
|
|
282
|
+
if let (Some((section, _)), Some(_), true, true, false) = (top, self.chunk, emit, first_in_group, breaks_first) {
|
|
283
|
+
let _ = writeln!(out, "#metadata({}) <{CUT_LABEL}>", cut_id(section, index));
|
|
284
|
+
}
|
|
203
285
|
match block {
|
|
204
286
|
Block::Paragraph(p) => {
|
|
205
287
|
let key = &keys[index];
|
|
206
|
-
let first_in_group = key.is_none() || index == 0 || keys[index - 1] != *key;
|
|
207
288
|
let last_in_group = key.is_none() || index + 1 == keys.len() || keys[index + 1] != *key;
|
|
208
289
|
let before = p.props.space_before.unwrap_or(0.0);
|
|
209
290
|
let same_style = prev_paragraph && p.props.style_id.as_deref() == prev_style;
|
|
210
291
|
let contextual = p.props.contextual_spacing.unwrap_or(false);
|
|
211
292
|
let lower = if same_style && prev_contextual { 0.0 } else { prev_after };
|
|
212
293
|
let upper = if same_style && contextual { 0.0 } else { before };
|
|
213
|
-
let gap = if
|
|
294
|
+
let gap = if leading {
|
|
295
|
+
0.0
|
|
296
|
+
} else if self.doc.additive_spacing {
|
|
214
297
|
lower + upper
|
|
215
298
|
} else {
|
|
216
299
|
lower.max(upper)
|
|
217
300
|
};
|
|
218
|
-
|
|
301
|
+
if emit {
|
|
302
|
+
self.paragraph(p, gap, first_in_group, last_in_group, &mut out);
|
|
303
|
+
}
|
|
219
304
|
prev_after = p.props.space_after.unwrap_or(0.0);
|
|
220
305
|
prev_style = p.props.style_id.as_deref();
|
|
221
306
|
prev_paragraph = true;
|
|
222
307
|
prev_contextual = contextual;
|
|
223
308
|
}
|
|
224
309
|
Block::Table(t) => {
|
|
225
|
-
if
|
|
226
|
-
|
|
310
|
+
if emit {
|
|
311
|
+
if prev_after > 0.01 && !leading {
|
|
312
|
+
let _ = writeln!(out, "#v({})", pt(prev_after));
|
|
313
|
+
}
|
|
314
|
+
self.table(t, &mut out);
|
|
227
315
|
}
|
|
228
|
-
self.table(t, &mut out);
|
|
229
316
|
prev_after = 0.0;
|
|
230
317
|
prev_style = None;
|
|
231
318
|
prev_paragraph = false;
|
|
232
319
|
prev_contextual = false;
|
|
233
320
|
}
|
|
234
321
|
Block::Columns(c) => {
|
|
235
|
-
if
|
|
236
|
-
|
|
322
|
+
if emit {
|
|
323
|
+
if prev_after > 0.01 && !leading {
|
|
324
|
+
let _ = writeln!(out, "#v({})", pt(prev_after));
|
|
325
|
+
}
|
|
326
|
+
self.columns_block(c, &mut out);
|
|
237
327
|
}
|
|
238
|
-
self.columns_block(c, &mut out);
|
|
239
328
|
prev_after = 0.0;
|
|
240
329
|
prev_style = None;
|
|
241
330
|
prev_paragraph = false;
|
|
242
331
|
prev_contextual = false;
|
|
243
332
|
}
|
|
244
333
|
}
|
|
334
|
+
if top.is_some() && self.chunk.is_none() {
|
|
335
|
+
if let Some(sizes) = self.block_sizes.borrow_mut().last_mut() {
|
|
336
|
+
sizes.push(out.len() - start);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
245
339
|
}
|
|
246
340
|
if trailing && prev_after > 0.01 {
|
|
247
341
|
let _ = writeln!(out, "#v({})", pt(prev_after));
|
|
@@ -431,7 +525,13 @@ impl Emitter<'_> {
|
|
|
431
525
|
any_text = true;
|
|
432
526
|
let value = match kind {
|
|
433
527
|
FieldKind::Page => format!("context counter(page).display({})", typst_str(self.page_pattern())),
|
|
434
|
-
FieldKind::NumPages =>
|
|
528
|
+
FieldKind::NumPages => {
|
|
529
|
+
self.uses_total_pages.set(true);
|
|
530
|
+
match self.chunk.and_then(|c| c.total_pages) {
|
|
531
|
+
Some(total) => typst_str(&total.to_string()),
|
|
532
|
+
None => "context counter(page).final().first()".to_string(),
|
|
533
|
+
}
|
|
534
|
+
}
|
|
435
535
|
};
|
|
436
536
|
items.push(self.text_expr_content(&value, props, spacing).to_string());
|
|
437
537
|
x = None;
|
|
@@ -1010,9 +1110,10 @@ impl Emitter<'_> {
|
|
|
1010
1110
|
}
|
|
1011
1111
|
|
|
1012
1112
|
fn register(&self, image: &ImageData) -> String {
|
|
1113
|
+
let (format, data) = super::images::sanitize(image.format, &image.data);
|
|
1013
1114
|
let mut files = self.files.borrow_mut();
|
|
1014
|
-
let path = format!("media/img{}.{}", files.len() + 1,
|
|
1015
|
-
files.push((path.clone(),
|
|
1115
|
+
let path = format!("media/img{}.{}", files.len() + 1, format.extension());
|
|
1116
|
+
files.push((path.clone(), data));
|
|
1016
1117
|
format!("/{path}")
|
|
1017
1118
|
}
|
|
1018
1119
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
use crate::limits::MAX_IMAGE_PIXELS;
|
|
2
|
+
use crate::model::ImageFormat;
|
|
2
3
|
use typst::foundations::Bytes;
|
|
3
4
|
use typst::visualize::{ExchangeFormat, RasterImage};
|
|
4
5
|
|
|
@@ -8,24 +9,28 @@ const PLACEHOLDER: [u8; 68] = [
|
|
|
8
9
|
0x02, 0x00, 0x00, 0x05, 0x00, 0x01, 0x7a, 0x5e, 0xab, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
|
9
10
|
];
|
|
10
11
|
|
|
11
|
-
pub fn sanitize(
|
|
12
|
-
let
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
pub fn sanitize(format: ImageFormat, data: &[u8]) -> (ImageFormat, Vec<u8>) {
|
|
13
|
+
let exchange = match format {
|
|
14
|
+
ImageFormat::Png => ExchangeFormat::Png,
|
|
15
|
+
ImageFormat::Jpeg => ExchangeFormat::Jpg,
|
|
16
|
+
ImageFormat::Gif => ExchangeFormat::Gif,
|
|
17
|
+
ImageFormat::Svg => return (format, data.to_vec()),
|
|
17
18
|
};
|
|
18
|
-
let within_limit = dimensions(
|
|
19
|
+
let within_limit = dimensions(exchange, data).is_some_and(|(w, h)| w > 0 && h > 0 && u64::from(w) * u64::from(h) <= MAX_IMAGE_PIXELS);
|
|
19
20
|
if !within_limit {
|
|
20
|
-
return
|
|
21
|
+
return placeholder();
|
|
21
22
|
}
|
|
22
|
-
let bytes = Bytes::new(data);
|
|
23
|
-
match RasterImage::plain(bytes.clone(),
|
|
24
|
-
Ok(_) => bytes.to_vec(),
|
|
25
|
-
Err(_) =>
|
|
23
|
+
let bytes = Bytes::new(data.to_vec());
|
|
24
|
+
match RasterImage::plain(bytes.clone(), exchange) {
|
|
25
|
+
Ok(_) => (format, bytes.to_vec()),
|
|
26
|
+
Err(_) => placeholder(),
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
fn placeholder() -> (ImageFormat, Vec<u8>) {
|
|
31
|
+
(ImageFormat::Png, PLACEHOLDER.to_vec())
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
fn dimensions(format: ExchangeFormat, data: &[u8]) -> Option<(u32, u32)> {
|
|
30
35
|
match format {
|
|
31
36
|
ExchangeFormat::Png => {
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
use crate::error::Error;
|
|
2
|
+
use std::io::Write;
|
|
3
|
+
|
|
4
|
+
struct Parsed<'a> {
|
|
5
|
+
data: &'a [u8],
|
|
6
|
+
spans: Vec<Option<(usize, usize)>>,
|
|
7
|
+
header_end: usize,
|
|
8
|
+
root: usize,
|
|
9
|
+
info: Option<usize>,
|
|
10
|
+
id: Option<&'a [u8]>,
|
|
11
|
+
pages: usize,
|
|
12
|
+
kids: Vec<usize>,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
pub fn merge(pdfs: &[Vec<u8>]) -> Result<Vec<u8>, Error> {
|
|
16
|
+
match pdfs {
|
|
17
|
+
[] => return Err(Error::new("cannot merge: no pages")),
|
|
18
|
+
[single] => return Ok(single.clone()),
|
|
19
|
+
_ => {}
|
|
20
|
+
}
|
|
21
|
+
let parsed = pdfs.iter().map(|pdf| parse(pdf)).collect::<Result<Vec<_>, _>>()?;
|
|
22
|
+
let objects: usize = parsed.iter().map(|p| p.spans.len() - 1).sum();
|
|
23
|
+
let pages_id = objects + 1;
|
|
24
|
+
let size = pages_id + 1;
|
|
25
|
+
|
|
26
|
+
let mut out = Vec::with_capacity(pdfs.iter().map(Vec::len).sum::<usize>() + size * 24);
|
|
27
|
+
out.extend_from_slice(&parsed[0].data[..parsed[0].header_end]);
|
|
28
|
+
let mut offsets: Vec<Option<usize>> = vec![None; size];
|
|
29
|
+
let mut kids = Vec::new();
|
|
30
|
+
let mut base = 0;
|
|
31
|
+
for (index, part) in parsed.iter().enumerate() {
|
|
32
|
+
let renumber = |id: usize| if id == part.pages { pages_id } else { base + id };
|
|
33
|
+
for (id, span) in part.spans.iter().enumerate() {
|
|
34
|
+
let Some((start, end)) = *span else {
|
|
35
|
+
continue;
|
|
36
|
+
};
|
|
37
|
+
let foreign_root = index > 0 && (id == part.root || Some(id) == part.info);
|
|
38
|
+
if id == part.pages || foreign_root {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
let object = &part.data[start..end];
|
|
42
|
+
let body_at = find(object, b"obj").ok_or_else(broken)? + 3;
|
|
43
|
+
offsets[base + id] = Some(out.len());
|
|
44
|
+
let _ = write!(out, "{} 0 obj", base + id);
|
|
45
|
+
rewrite(&object[body_at..], &renumber, &mut out);
|
|
46
|
+
}
|
|
47
|
+
kids.extend(part.kids.iter().map(|&kid| base + kid));
|
|
48
|
+
base += part.spans.len() - 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
offsets[pages_id] = Some(out.len());
|
|
52
|
+
let _ = write!(out, "{pages_id} 0 obj\n<</Type/Pages/Count {}/Kids[", kids.len());
|
|
53
|
+
for (i, kid) in kids.iter().enumerate() {
|
|
54
|
+
let _ = write!(out, "{}{kid} 0 R", if i > 0 { " " } else { "" });
|
|
55
|
+
}
|
|
56
|
+
out.extend_from_slice(b"]>>\nendobj\n");
|
|
57
|
+
|
|
58
|
+
let xref = out.len();
|
|
59
|
+
let _ = write!(out, "xref\n0 {size}\n");
|
|
60
|
+
for offset in &offsets {
|
|
61
|
+
match offset {
|
|
62
|
+
Some(at) => {
|
|
63
|
+
let _ = write!(out, "{at:010} 00000 n \n");
|
|
64
|
+
}
|
|
65
|
+
None => out.extend_from_slice(b"0000000000 65535 f \n"),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
let first = &parsed[0];
|
|
69
|
+
let _ = write!(out, "trailer\n<</Size {size}/Root {} 0 R", first.root);
|
|
70
|
+
if let Some(info) = first.info {
|
|
71
|
+
let _ = write!(out, "/Info {info} 0 R");
|
|
72
|
+
}
|
|
73
|
+
if let Some(id) = first.id {
|
|
74
|
+
out.extend_from_slice(b"/ID");
|
|
75
|
+
out.extend_from_slice(id);
|
|
76
|
+
}
|
|
77
|
+
let _ = write!(out, ">>\nstartxref\n{xref}\n%%EOF\n");
|
|
78
|
+
Ok(out)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#[cfg(test)]
|
|
82
|
+
pub fn check(pdf: &[u8]) -> Result<usize, Error> {
|
|
83
|
+
let parsed = parse(pdf)?;
|
|
84
|
+
let referenced = std::cell::RefCell::new(Vec::new());
|
|
85
|
+
for (start, end) in parsed.spans.iter().flatten().copied() {
|
|
86
|
+
let object = &pdf[start..end];
|
|
87
|
+
let body_at = find(object, b"obj").ok_or_else(broken)? + 3;
|
|
88
|
+
rewrite(
|
|
89
|
+
&object[body_at..],
|
|
90
|
+
&|id| {
|
|
91
|
+
referenced.borrow_mut().push(id);
|
|
92
|
+
id
|
|
93
|
+
},
|
|
94
|
+
&mut Vec::new(),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
for id in referenced.into_inner() {
|
|
98
|
+
if parsed.spans.get(id).copied().flatten().is_none() {
|
|
99
|
+
return Err(Error::new(format!("dangling reference to object {id}")));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
let parent = format!("/Parent {} 0 R", parsed.pages);
|
|
103
|
+
for &kid in &parsed.kids {
|
|
104
|
+
let (start, end) = parsed.spans.get(kid).copied().flatten().ok_or_else(broken)?;
|
|
105
|
+
let page = &pdf[start..end];
|
|
106
|
+
if find(page, b"/Type/Page").is_none() || find(page, parent.as_bytes()).is_none() {
|
|
107
|
+
return Err(Error::new(format!("page object {kid} is not a child of the page tree")));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
Ok(parsed.kids.len())
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
fn broken() -> Error {
|
|
114
|
+
Error::new("cannot merge: unexpected PDF structure")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fn parse(data: &[u8]) -> Result<Parsed<'_>, Error> {
|
|
118
|
+
let startxref = rfind(data, b"startxref").ok_or_else(broken)?;
|
|
119
|
+
let xref = number_after(data, startxref + 9).ok_or_else(broken)?;
|
|
120
|
+
if !data.get(xref..).is_some_and(|rest| rest.starts_with(b"xref")) {
|
|
121
|
+
return Err(broken());
|
|
122
|
+
}
|
|
123
|
+
let trailer_at = xref + find(&data[xref..], b"trailer").ok_or_else(broken)?;
|
|
124
|
+
let table = std::str::from_utf8(&data[xref + 4..trailer_at]).map_err(|_| broken())?;
|
|
125
|
+
let mut tokens = table.split_ascii_whitespace();
|
|
126
|
+
let mut entries: Vec<(usize, usize)> = Vec::new();
|
|
127
|
+
let mut size = 0;
|
|
128
|
+
while let (Some(first), Some(count)) = (tokens.next(), tokens.next()) {
|
|
129
|
+
let first: usize = first.parse().map_err(|_| broken())?;
|
|
130
|
+
let count: usize = count.parse().map_err(|_| broken())?;
|
|
131
|
+
for id in first..first + count {
|
|
132
|
+
let (Some(offset), Some(_), Some(kind)) = (tokens.next(), tokens.next(), tokens.next()) else {
|
|
133
|
+
return Err(broken());
|
|
134
|
+
};
|
|
135
|
+
if kind == "n" {
|
|
136
|
+
entries.push((offset.parse().map_err(|_| broken())?, id));
|
|
137
|
+
}
|
|
138
|
+
size = size.max(id + 1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
entries.sort_unstable();
|
|
142
|
+
let mut spans = vec![None; size];
|
|
143
|
+
for (i, &(offset, id)) in entries.iter().enumerate() {
|
|
144
|
+
let end = entries.get(i + 1).map_or(xref, |&(next, _)| next);
|
|
145
|
+
if offset >= end || !data[offset..].starts_with(format!("{id} 0 obj").as_bytes()) {
|
|
146
|
+
return Err(broken());
|
|
147
|
+
}
|
|
148
|
+
spans[id] = Some((offset, end));
|
|
149
|
+
}
|
|
150
|
+
let header_end = entries.first().map(|&(offset, _)| offset).ok_or_else(broken)?;
|
|
151
|
+
|
|
152
|
+
let trailer = &data[trailer_at..startxref];
|
|
153
|
+
let root = reference_after(trailer, b"/Root").ok_or_else(broken)?;
|
|
154
|
+
let info = reference_after(trailer, b"/Info");
|
|
155
|
+
let id = find(trailer, b"/ID").and_then(|at| {
|
|
156
|
+
let rest = &trailer[at + 3..];
|
|
157
|
+
let open = rest.iter().position(|&c| c == b'[')?;
|
|
158
|
+
let close = rest.iter().position(|&c| c == b']')?;
|
|
159
|
+
(open < close).then(|| &rest[open..=close])
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
let object = |id: usize| spans.get(id).copied().flatten().map(|(start, end)| &data[start..end]);
|
|
163
|
+
let catalog = object(root).ok_or_else(broken)?;
|
|
164
|
+
let pages = reference_after(catalog, b"/Pages").ok_or_else(broken)?;
|
|
165
|
+
let tree = object(pages).ok_or_else(broken)?;
|
|
166
|
+
let kids_at = find(tree, b"/Kids").ok_or_else(broken)?;
|
|
167
|
+
let kids_end = kids_at + find(&tree[kids_at..], b"]").ok_or_else(broken)?;
|
|
168
|
+
let kids: Vec<usize> = std::str::from_utf8(&tree[kids_at + 5..kids_end])
|
|
169
|
+
.map_err(|_| broken())?
|
|
170
|
+
.trim_start_matches(|c: char| c == '[' || c.is_ascii_whitespace())
|
|
171
|
+
.split('R')
|
|
172
|
+
.filter_map(|r| r.split_ascii_whitespace().next().and_then(|n| n.parse().ok()))
|
|
173
|
+
.collect();
|
|
174
|
+
for &kid in &kids {
|
|
175
|
+
let page = object(kid).ok_or_else(broken)?;
|
|
176
|
+
if find(page, b"/Type/Pages").is_some() {
|
|
177
|
+
return Err(broken());
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
Ok(Parsed {
|
|
182
|
+
data,
|
|
183
|
+
spans,
|
|
184
|
+
header_end,
|
|
185
|
+
root,
|
|
186
|
+
info,
|
|
187
|
+
id,
|
|
188
|
+
pages,
|
|
189
|
+
kids,
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
fn rewrite(src: &[u8], renumber: &impl Fn(usize) -> usize, out: &mut Vec<u8>) {
|
|
194
|
+
let mut i = 0;
|
|
195
|
+
let mut nesting = 0usize;
|
|
196
|
+
while i < src.len() {
|
|
197
|
+
let c = src[i];
|
|
198
|
+
if nesting > 0 {
|
|
199
|
+
out.push(c);
|
|
200
|
+
match c {
|
|
201
|
+
b'\\' if i + 1 < src.len() => {
|
|
202
|
+
out.push(src[i + 1]);
|
|
203
|
+
i += 1;
|
|
204
|
+
}
|
|
205
|
+
b'(' => nesting += 1,
|
|
206
|
+
b')' => nesting -= 1,
|
|
207
|
+
_ => {}
|
|
208
|
+
}
|
|
209
|
+
i += 1;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
let boundary = i == 0 || !is_regular(src[i - 1]);
|
|
213
|
+
if c == b'(' {
|
|
214
|
+
nesting = 1;
|
|
215
|
+
} else if boundary && src[i..].starts_with(b"stream") && src.get(i + 6).is_some_and(|&n| n == b'\r' || n == b'\n') {
|
|
216
|
+
out.extend_from_slice(&src[i..]);
|
|
217
|
+
return;
|
|
218
|
+
} else if boundary && c.is_ascii_digit() {
|
|
219
|
+
if let Some((id, len)) = reference(&src[i..]) {
|
|
220
|
+
let _ = write!(out, "{} 0 R", renumber(id));
|
|
221
|
+
i += len;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
while i < src.len() && is_regular(src[i]) {
|
|
225
|
+
out.push(src[i]);
|
|
226
|
+
i += 1;
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
out.push(c);
|
|
231
|
+
i += 1;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
fn reference(src: &[u8]) -> Option<(usize, usize)> {
|
|
236
|
+
let digits = |from: usize| src[from..].iter().take_while(|c| c.is_ascii_digit()).count();
|
|
237
|
+
let spaces = |from: usize| src[from..].iter().take_while(|c| c.is_ascii_whitespace()).count();
|
|
238
|
+
let id_len = digits(0);
|
|
239
|
+
let mut at = id_len;
|
|
240
|
+
let gap = spaces(at);
|
|
241
|
+
if gap == 0 {
|
|
242
|
+
return None;
|
|
243
|
+
}
|
|
244
|
+
at += gap;
|
|
245
|
+
let generation = digits(at);
|
|
246
|
+
if generation == 0 {
|
|
247
|
+
return None;
|
|
248
|
+
}
|
|
249
|
+
at += generation;
|
|
250
|
+
let gap = spaces(at);
|
|
251
|
+
if gap == 0 {
|
|
252
|
+
return None;
|
|
253
|
+
}
|
|
254
|
+
at += gap;
|
|
255
|
+
if src.get(at) != Some(&b'R') || src.get(at + 1).is_some_and(|&c| is_regular(c)) {
|
|
256
|
+
return None;
|
|
257
|
+
}
|
|
258
|
+
let id = std::str::from_utf8(&src[..id_len]).ok()?.parse().ok()?;
|
|
259
|
+
Some((id, at + 1))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
fn is_regular(c: u8) -> bool {
|
|
263
|
+
!c.is_ascii_whitespace() && !b"()<>[]{}/%".contains(&c)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
|
267
|
+
haystack.windows(needle.len()).position(|w| w == needle)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
fn rfind(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
|
271
|
+
haystack.windows(needle.len()).rposition(|w| w == needle)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
fn number_after(data: &[u8], from: usize) -> Option<usize> {
|
|
275
|
+
let rest = data.get(from..)?;
|
|
276
|
+
let start = rest.iter().position(|c| c.is_ascii_digit())?;
|
|
277
|
+
let len = rest[start..].iter().take_while(|c| c.is_ascii_digit()).count();
|
|
278
|
+
std::str::from_utf8(&rest[start..start + len]).ok()?.parse().ok()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
fn reference_after(data: &[u8], key: &[u8]) -> Option<usize> {
|
|
282
|
+
let at = find(data, key)? + key.len();
|
|
283
|
+
let rest = &data[at..];
|
|
284
|
+
let start = rest.iter().position(|c| !c.is_ascii_whitespace())?;
|
|
285
|
+
reference(&rest[start..]).map(|(id, _)| id)
|
|
286
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
mod chunks;
|
|
1
2
|
pub mod emit;
|
|
2
3
|
pub mod fonts;
|
|
3
4
|
mod images;
|
|
5
|
+
mod merge;
|
|
4
6
|
mod notes;
|
|
5
7
|
pub mod world;
|
|
6
8
|
|
|
@@ -22,24 +24,23 @@ pub fn render_pdf(mut document: Document, fonts_dir: &Path) -> Result<Vec<u8>, E
|
|
|
22
24
|
if let Ok(path) = std::env::var("SIMPLE_CONVERTER_DUMP_TYPST") {
|
|
23
25
|
let _ = std::fs::write(path, &emitted.source);
|
|
24
26
|
}
|
|
25
|
-
|
|
27
|
+
let loaded = std::env::var("SIMPLE_CONVERTER_LOAD_TYPST").ok();
|
|
28
|
+
if let Some(path) = &loaded {
|
|
26
29
|
emitted.source = std::fs::read_to_string(path).map_err(|e| Error::new(format!("cannot read typst source: {e}")))?;
|
|
27
30
|
}
|
|
31
|
+
crate::limits::check_layout_source(emitted.source.len())?;
|
|
28
32
|
|
|
29
|
-
let
|
|
30
|
-
|
|
31
|
-
let
|
|
32
|
-
|
|
33
|
+
let has_notes = doc.sections.iter().any(|s| !s.notes.is_empty());
|
|
34
|
+
if loaded.is_none() && !has_notes && emitted.source.len() > chunks::CHUNK_SOURCE_BYTES {
|
|
35
|
+
let block_sizes = std::mem::take(&mut emitted.block_sizes);
|
|
36
|
+
let uses_total_pages = emitted.uses_total_pages;
|
|
37
|
+
drop(emitted);
|
|
38
|
+
return chunks::render(doc, &fonts, &block_sizes, uses_total_pages);
|
|
33
39
|
}
|
|
34
|
-
let compiled = typst::compile::<PagedDocument>(&world);
|
|
35
|
-
let paged = compiled
|
|
36
|
-
.output
|
|
37
|
-
.map_err(|errors| Error::new(format!("layout failed: {}", diagnostics(&errors))))?;
|
|
38
40
|
|
|
39
|
-
let
|
|
40
|
-
|
|
41
|
+
let paged = layout(emitted, fonts)?;
|
|
42
|
+
let mut pdf = export(&paged, &PdfOptions::default())?;
|
|
41
43
|
|
|
42
|
-
let has_notes = doc.sections.iter().any(|s| !s.notes.is_empty());
|
|
43
44
|
if has_notes && paged.pages().len() == doc.sections.len() {
|
|
44
45
|
let pages: Vec<(f64, &[crate::model::PageNote])> =
|
|
45
46
|
doc.sections.iter().map(|s| (s.page.height, s.notes.as_slice())).collect();
|
|
@@ -50,6 +51,20 @@ pub fn render_pdf(mut document: Document, fonts_dir: &Path) -> Result<Vec<u8>, E
|
|
|
50
51
|
Ok(pdf)
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
fn layout(emitted: emit::Emitted, fonts: FontSet) -> Result<PagedDocument, Error> {
|
|
55
|
+
let mut world = ConverterWorld::new(emitted.source, fonts);
|
|
56
|
+
for (path, data) in emitted.files {
|
|
57
|
+
world.add_file(&path, data)?;
|
|
58
|
+
}
|
|
59
|
+
typst::compile::<PagedDocument>(&world)
|
|
60
|
+
.output
|
|
61
|
+
.map_err(|errors| Error::new(format!("layout failed: {}", diagnostics(&errors))))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fn export(paged: &PagedDocument, options: &PdfOptions) -> Result<Vec<u8>, Error> {
|
|
65
|
+
typst_pdf::pdf(paged, options).map_err(|errors| Error::new(format!("pdf export failed: {}", diagnostics(&errors))))
|
|
66
|
+
}
|
|
67
|
+
|
|
53
68
|
fn diagnostics(errors: &[SourceDiagnostic]) -> String {
|
|
54
69
|
let mut messages: Vec<String> = Vec::new();
|
|
55
70
|
for error in errors {
|
package/core/src/xlsx/mod.rs
CHANGED
|
@@ -1186,7 +1186,7 @@ fn paginate(sheet: &Sheet, name: &str, styles: &Styles, cells: &mut u64) -> Resu
|
|
|
1186
1186
|
for (rg, cg) in &order {
|
|
1187
1187
|
*cells += (rg.len() * cg.len()) as u64;
|
|
1188
1188
|
if *cells > crate::limits::MAX_SHEET_CELLS {
|
|
1189
|
-
return Err(Error::
|
|
1189
|
+
return Err(Error::limit(format!("the spreadsheet is too large to convert: more than {} cells to print", crate::limits::MAX_SHEET_CELLS)));
|
|
1190
1190
|
}
|
|
1191
1191
|
}
|
|
1192
1192
|
|
package/core/src/xlsx/ods.rs
CHANGED
|
@@ -389,7 +389,7 @@ fn read_table(table: &Element, odf: &OdfStyles, formats: &mut Formats, pkg: &Pac
|
|
|
389
389
|
let repeats = if has_content || !template.cells.is_empty() { count.min(4096) } else { count };
|
|
390
390
|
materialised += template.cells.len() as u64 * u64::from(repeats);
|
|
391
391
|
if materialised > crate::limits::MAX_SHEET_CELLS {
|
|
392
|
-
return Err(Error::
|
|
392
|
+
return Err(Error::limit(format!("the spreadsheet is too large to convert: more than {} cells", crate::limits::MAX_SHEET_CELLS)));
|
|
393
393
|
}
|
|
394
394
|
if !template.cells.is_empty() || height.is_some() || hidden {
|
|
395
395
|
for k in 0..repeats.min(if template.cells.is_empty() { 1 } else { repeats }) {
|
package/core/src/xml.rs
CHANGED
|
@@ -64,12 +64,23 @@ pub fn parse(bytes: &[u8]) -> Result<Element, Error> {
|
|
|
64
64
|
name: String::from("#document"),
|
|
65
65
|
..Element::default()
|
|
66
66
|
}];
|
|
67
|
+
let mut elements: usize = 0;
|
|
67
68
|
|
|
68
69
|
loop {
|
|
69
|
-
|
|
70
|
+
let event = reader.read_event()?;
|
|
71
|
+
if matches!(event, Event::Start(_) | Event::Empty(_)) {
|
|
72
|
+
elements += 1;
|
|
73
|
+
if elements > crate::limits::MAX_XML_ELEMENTS {
|
|
74
|
+
return Err(Error::limit(format!(
|
|
75
|
+
"document is too large to convert: a part has more than {} XML elements",
|
|
76
|
+
crate::limits::MAX_XML_ELEMENTS
|
|
77
|
+
)));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
match event {
|
|
70
81
|
Event::Start(start) => {
|
|
71
82
|
if stack.len() > crate::limits::MAX_XML_DEPTH {
|
|
72
|
-
return Err(Error::
|
|
83
|
+
return Err(Error::limit(format!("xml: elements are nested more than {} levels deep", crate::limits::MAX_XML_DEPTH)));
|
|
73
84
|
}
|
|
74
85
|
stack.push(element(&start)?)
|
|
75
86
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bare-x-to-pdf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Convert Word, PowerPoint, Excel and OpenDocument files to PDF for Bare: a Rust engine on Typst",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
@@ -118,16 +118,16 @@
|
|
|
118
118
|
}
|
|
119
119
|
},
|
|
120
120
|
"optionalDependencies": {
|
|
121
|
-
"bare-x-to-pdf-android-arm": "0.
|
|
122
|
-
"bare-x-to-pdf-android-arm64": "0.
|
|
123
|
-
"bare-x-to-pdf-android-x64": "0.
|
|
124
|
-
"bare-x-to-pdf-darwin-arm64": "0.
|
|
125
|
-
"bare-x-to-pdf-darwin-x64": "0.
|
|
126
|
-
"bare-x-to-pdf-ios-arm64": "0.
|
|
127
|
-
"bare-x-to-pdf-ios-arm64-simulator": "0.
|
|
128
|
-
"bare-x-to-pdf-ios-x64-simulator": "0.
|
|
129
|
-
"bare-x-to-pdf-linux-arm64": "0.
|
|
130
|
-
"bare-x-to-pdf-linux-x64": "0.
|
|
131
|
-
"bare-x-to-pdf-win32-x64": "0.
|
|
121
|
+
"bare-x-to-pdf-android-arm": "0.6.0",
|
|
122
|
+
"bare-x-to-pdf-android-arm64": "0.6.0",
|
|
123
|
+
"bare-x-to-pdf-android-x64": "0.6.0",
|
|
124
|
+
"bare-x-to-pdf-darwin-arm64": "0.6.0",
|
|
125
|
+
"bare-x-to-pdf-darwin-x64": "0.6.0",
|
|
126
|
+
"bare-x-to-pdf-ios-arm64": "0.6.0",
|
|
127
|
+
"bare-x-to-pdf-ios-arm64-simulator": "0.6.0",
|
|
128
|
+
"bare-x-to-pdf-ios-x64-simulator": "0.6.0",
|
|
129
|
+
"bare-x-to-pdf-linux-arm64": "0.6.0",
|
|
130
|
+
"bare-x-to-pdf-linux-x64": "0.6.0",
|
|
131
|
+
"bare-x-to-pdf-win32-x64": "0.6.0"
|
|
132
132
|
}
|
|
133
133
|
}
|
|
Binary file
|