Compare commits

...
Author SHA1 Message Date
aiongg 569f10315b Apply review fixes
- crates/pdf/src/lib.rs
2026-11-04 23:33:11 +00:00
aiongg dc587a1036 Merge main into fix/sec-pdf-export-work 2026-11-04 23:23:29 +00:00
.CooCoo 85de6ca5d7 Shape Fill: the gradient help says which kinds rotateWithShape keeps upright (#1843)
With rotateWithShape false, the renderer lays linear and radial gradients
out upright on the slide, while rectangular and path gradients still turn
with the shape. The shape.fill params doc now says so.
2026-11-04 18:11:29 -05:00
Harrison Booth fc18a78e65 comment: reject malformed navigation indices (#1547) 2026-11-04 18:09:50 -05:00
Dylan M. Taylor 31abd2d819 Pass the Linux signing key to the release workflow (#1868) 2026-11-04 17:45:40 -05:00
aiongg 144fd28f69 Merge pull request #1192 from MarekSpodBiedry/issue-1153
Use evaluated field values in the placed text so the PDF text layer shows the slide number (#1153)
2026-11-04 16:15:06 -05:00
aiongg 65ddd8ad51 Apply review fixes
- crates/render/src/placed.rs
2026-11-04 20:55:43 +00:00
Mex 9d36b5931c Ogg: include recovered tail packets in Opus duration (#1862)
* Ogg: include recovered tail packets in Opus duration

Keep the existing missing-granule recovery cursor in step with the packet ends it assigns. Add a regression for a partially and entirely unanchored stream; existing valid end-trimming coverage remains unchanged.

Validation: rustc --edition=2024 --test crates/ogg/src/lib.rs: all 12 tests pass. Scoped rustfmt check passes. Full cargo --offline xtask ci is blocked before build by the uncached pinned egui checkout.

* Ogg: record recovery duration correction
2026-11-04 15:15:59 -05:00
aiongg c252a681a0 Merge pull request #1454 from Silvertree2010/fix/h3020-rtf-outline-import-drops-the-res
Keep whole character after a backslash in RTF outline import
2026-11-04 14:43:02 -05:00
Dylan M. Taylor 80f784d210 Fonts: ignore variation tags the font has no axis for (#1795) 2026-11-04 14:36:13 -05:00
Dylan M. Taylor 07dc2210a1 Repair: bound the notes page size like a slide (#1832) 2026-11-04 14:09:46 -05:00
Dylan M. Taylor 51d19baa0c Ogg, xtask: check packet ranges against the source first; stats skips directory links (#1796) 2026-11-04 13:56:48 -05:00
MarekSpodBiedry 50187449c2 Render tests: import place_slide_with so the date test compiles (#1153) 2026-11-04 07:52:46 +01:00
MarekSpodBiedry b8de82e2cc Merge remote-tracking branch 'origin/main' into review-1192 2026-11-04 07:51:04 +01:00
Dylan M. Taylor 27787121c4 address review: require the huge-paper export to succeed 2026-11-04 04:41:06 +00:00
Dylan M. Taylor 23ac4f7efd Fonts: bound font-controlled work in enumeration, outlines and carets (#1701)
* Fonts: bound font-controlled work in enumeration, outlines and carets

An embedded font could make each lookup do far more than real fonts need:
- enumerating a collection read up to 256 faces times 1024 named instances,
  each then built with its own shaper; now 4096 per file across all faces,
  with a set for the duplicate-style check instead of a scan;
- composite and VARC glyphs could expand one dense outline without limit,
  and the outline cache only counted entries; outlines now stop at 32768
  path elements (drawing nothing past it) and the cache is also bounded by
  the elements it holds;
- the 64-caret limit applied after filtering, so a ligature lookup could
  read every caret record; it now applies to the records read.

* address review: bound thread-local glyph cache by path elements
2026-11-03 20:39:57 -05:00
Dylan M. Taylor 69c91bb265 Render: index table style, skip list, chart point fills and waterfall bars; count each dash (#1774)
* render: look up a table's style once, keep the skip list as a set, count each dash

* render: index chart point fills, scatter x presence and waterfall bars once per chart
2026-11-03 20:39:36 -05:00
Dylan M. Taylor a790fdbc20 Render: find a glyph's line by bisection for gradient and WordArt text (#1783) 2026-11-03 20:39:14 -05:00
Dylan M. Taylor 9d59fa96d3 Text, morph: share a run's gradient between glyph runs, match morph tokens by text (#1784) 2026-11-03 20:38:53 -05:00
Dylan M. Taylor 2dfb07f2c4 Thumbnails: index section starts once (#1800) 2026-11-03 20:38:31 -05:00
Dylan M. Taylor f5fbadde63 Render: evict the least recently used animation instead of clearing the GIF cache (#1802) 2026-11-03 20:38:10 -05:00
Dylan M. Taylor 996bb42ff5 pptx: index relationships by id on read and by target on write (#1739) 2026-11-03 20:12:47 -05:00
Dylan M. Taylor 3c3d504eb1 PPTX: read a notes or comments part for the first slide that names it (#1741)
* pptx: read a notes or comments part for the first slide that names it

* address review: keep modern-comments precedence for already-owned parts
2026-11-03 20:12:24 -05:00
Dylan M. Taylor f5511d93b6 Compare: index slide, shape and media lookups and compare each picture pair once (#1752)
* engine: index review.compare lookups and compare each picture pair once

* engine: keep the compare test's inserted slides empty
2026-11-03 20:12:03 -05:00
Dylan M. Taylor c6b7de43bc engine: use sets for slide-range expansion, added slide selection and repeated-word offsets (#1756) 2026-11-03 20:05:53 -05:00
Dylan M. Taylor 65d5c28512 Text: sort a paragraph's tab stops once and find the next by binary search (#1757)
* text: sort a paragraph's tab stops once and find the next by binary search

* address review: skip the tab-stop lookup for a NaN position
2026-11-03 20:05:32 -05:00
Dylan M. Taylor d2a8be197d geom: measure each follow band once when mapping warped lines (#1758) 2026-11-03 20:05:11 -05:00
Dylan M. Taylor 872cdbf331 pptx: take nested math text once and check an object's part for relationships once (#1762) 2026-11-03 20:04:49 -05:00
Dylan M. Taylor 0233d29064 Spelling: bound a word's language and index results by offset (#1767)
* engine: bound a word's language and index spelling results by offset

* engine: pass the test run's text by value
2026-11-03 19:59:51 -05:00
Dylan M. Taylor a72a5167d0 Morph: pick the next curve to split from a heap, not a full scan (#1782) 2026-11-03 19:59:29 -05:00
Dylan M. Taylor b7d57bacab Text edit: splice inserted paragraphs once and paste runs without rescanning (#1787) 2026-11-03 19:54:06 -05:00
Dylan M. Taylor 42108d0ee7 PPTX export: index animation targets, cache build roles, serialize run properties once per text run (#1788)
* PPTX export: index animation targets, cache a paragraph's build roles, serialize run properties once per text run

* address review: warn once when a by-paragraph build is truncated
2026-11-03 19:53:44 -05:00
Dylan M. Taylor 0107186cee Engine: check the temporary file's path against the write root (#1792) 2026-11-03 19:46:09 -05:00
Dylan M. Taylor 54e24ab868 Review: replace each whole word in one pass (#1793) 2026-11-03 19:36:05 -05:00
Dylan M. Taylor 31a7c2baf5 address review: use a narrow tall page in the bounded-work test 2026-11-03 21:57:28 +00:00
Dylan M. Taylor 19dceffd0b pptx: index media, layouts, sections and table styles on export (#1761) 2026-11-03 14:38:07 -05:00
Dylan M. Taylor 1eec8b266c model: group box charts and stack sums in one pass, share hierarchy labels, cap number formats (#1760) 2026-11-03 14:14:24 -05:00
Dylan M. Taylor 32f069eb84 AV1, HEVC: count repeated-frame outputs incrementally and drop inherited entry-point storage (#1763)
* av1: count repeated-frame outputs as they are added

* hevc: give each slice segment its own entry-point list instead of keeping the copied one's allocation
2026-11-03 13:44:00 -05:00
Dylan M. Taylor 74d6145982 pdf: spell the rule-count check without a negated comparison 2026-11-03 14:20:42 +00:00
Dylan M. Taylor ee04033524 pdf: gather a paragraph's text once, keep note line widths, count handout rules 2026-11-03 11:41:27 +00:00
MarekSpodBiedry 74d7b844fe Merge origin/main into issue-1153 2026-11-01 07:41:05 +01:00
NetterSilverandClaude Haiku 5.5 d8936f94af Keep whole character after a backslash in RTF outline import
Found while testing crates/format/src/rtf.rs:301-326 (symbol / text, reached via escape)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 10:58:38 +01:00
MarekSpodBiedry 813378cc20 Keep the PDF text layer aligned with line breaks, use one clock reading for raster and text layer, and test the page text (#1153) 2026-10-23 16:08:53 +02:00
MarekSpodBiedry cc6773c95f Use evaluated field values in the placed text so the PDF text layer shows the slide number (#1153) 2026-10-22 21:02:59 +02:00
58 changed files with 1611 additions and 277 deletions
+1
View File
@@ -45,3 +45,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
+4
View File
@@ -2,6 +2,8 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away **Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-04 · **Change:** trivial (Ogg Opus recovery includes trailing packets without granules in its reported duration; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -150,6 +152,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed | | Date | What landed |
|---|---| |---|---|
| 2026-11-04 | Ogg Opus recovery keeps trailing packets without granules in the total duration, matching their recovered positions; valid end trimming stays covered |
| 2026-11-02 | Native and PPTX saves retain optional table right-to-left direction, including explicit false and omission; two-save round-trip tests keep labeled columns in order | | 2026-11-02 | Native and PPTX saves retain optional table right-to-left direction, including explicit false and omission; two-save round-trip tests keep labeled columns in order |
| 2026-11-02 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves | | 2026-11-02 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves |
| 2026-11-02 | Picture compression measures displayed dimensions through nested group transforms; checklist score unchanged | | 2026-11-02 | Picture compression measures displayed dimensions through nested group transforms; checklist score unchanged |
@@ -330,6 +333,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-11-04 | trivial | Ogg Opus recovery duration includes its recovered tail; parity scores unchanged |
| 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged | | 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged |
| 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged | | 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged |
| 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained | | 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained |
+4 -1
View File
@@ -425,6 +425,8 @@ impl Decoder {
fn parse_obus(&mut self, data: &[u8], out: &mut Vec<(Output, usize, u32)>) -> Result<()> { fn parse_obus(&mut self, data: &[u8], out: &mut Vec<(Output, usize, u32)>) -> Result<()> {
let mut tu = 0usize; let mut tu = 0usize;
// Repeated-frame outputs in `out`, counted as they are added rather than recounted per OBU.
let mut existing = out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count();
let mut pos = 0; let mut pos = 0;
while pos < data.len() { while pos < data.len() {
let obu = parse_obu_unit(data, pos)?; let obu = parse_obu_unit(data, pos)?;
@@ -444,9 +446,10 @@ impl Decoder {
} }
OBU_FRAME_HEADER_T | OBU_REDUNDANT_FRAME_HEADER_T | OBU_FRAME_T => { OBU_FRAME_HEADER_T | OBU_REDUNDANT_FRAME_HEADER_T | OBU_FRAME_T => {
if let Some(o) = self.frame_obu(&obu)? { if let Some(o) = self.frame_obu(&obu)? {
if out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count() >= 32 { if existing >= 32 {
return Err(Error::Invalid("too many frame outputs in chunk")); return Err(Error::Invalid("too many frame outputs in chunk"));
} }
existing += usize::from(matches!(o, Output::Existing { .. }));
out.push((o, tu, spatial_id)); out.push((o, tu, spatial_id));
} }
} }
+12 -1
View File
@@ -397,6 +397,7 @@ pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions>
pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> { pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5")); let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5"));
let mut out: Vec<usize> = vec![]; let mut out: Vec<usize> = vec![];
let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) { for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
let (a, b) = match part.split_once('-') { let (a, b) = match part.split_once('-') {
Some((a, b)) => (a.trim(), b.trim()), Some((a, b)) => (a.trim(), b.trim()),
@@ -412,7 +413,7 @@ pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them"))); return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them")));
} }
for i in lo - 1..hi { for i in lo - 1..hi {
if !out.contains(&i) { if seen.insert(i) {
out.push(i); out.push(i);
} }
} }
@@ -776,6 +777,16 @@ mod tests {
use super::{parse_slide_range, write_file}; use super::{parse_slide_range, write_file};
#[test]
fn broad_and_repeated_slide_ranges_expand_once() {
// 200 copies of a 100,000-slide range: each index once, in order, without a scan per index.
let text = vec!["1-100000"; 200].join(",");
let v = parse_slide_range(&text, 100_000).unwrap();
assert_eq!(v.len(), 100_000);
assert!(v.windows(2).all(|w| w[0] < w[1]));
assert_eq!(parse_slide_range("3, 1-3, 2", 3).unwrap(), vec![2, 0, 1]);
}
#[test] #[test]
fn slide_range_rejects_plus_sign() { fn slide_range_rejects_plus_sign() {
assert!(parse_slide_range("+2", 3).is_err()); assert!(parse_slide_range("+2", 3).is_err());
+91 -7
View File
@@ -219,7 +219,7 @@ fn neighbour(s: &mut Session, p: &Value, forward: bool) -> Result<Value> {
let cmd = if forward { "comment.next" } else { "comment.prev" }; let cmd = if forward { "comment.next" } else { "comment.prev" };
let (threads, slide, index) = { let (threads, slide, index) = {
let st = s.doc()?; let st = s.doc()?;
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), usize_param(p, "index")) (comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), super::opt_usize_param(p, "index", cmd)?)
}; };
if threads.is_empty() { if threads.is_empty() {
return Err(bad(cmd, "no comments")); return Err(bad(cmd, "no comments"));
@@ -367,7 +367,7 @@ fn spelling(s: &mut Session, p: &Value) -> Result<Value> {
} }
out.push(v); out.push(v);
} }
let rep: Vec<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect(); let rep: std::collections::HashSet<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
for w in crate::spell::paragraph_words(para, &default) { for w in crate::spell::paragraph_words(para, &default) {
if ignore.contains(&w.text.to_lowercase()) || rep.contains(&w.start) { if ignore.contains(&w.text.to_lowercase()) || rep.contains(&w.start) {
continue; continue;
@@ -421,23 +421,30 @@ fn add_to_dictionary(_s: &mut Session, p: &Value) -> Result<Value> {
/// Replace whole-word `from` with `to` in `text`; how many were replaced. /// Replace whole-word `from` with `to` in `text`; how many were replaced.
fn replace_word(text: &mut String, from: &str, to: &str, all: bool) -> usize { fn replace_word(text: &mut String, from: &str, to: &str, all: bool) -> usize {
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’'; let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
let mut n = 0; // The result is built in one pass; the text is not edited in place for every match.
let mut at = 0; let mut out = String::new();
let (mut n, mut at, mut copied) = (0, 0, 0);
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) { while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
let end = i + from.len(); let end = i + from.len();
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word); // The character before the match as the edited text would have it.
let before = text.get(copied..i).and_then(|t| t.chars().next_back()).or_else(|| out.chars().next_back()).is_some_and(is_word);
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word); let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
if before || after { if before || after {
at = end; at = end;
continue; continue;
} }
text.replace_range(i..end, to); out.push_str(text.get(copied..i).unwrap_or_default());
out.push_str(to);
n += 1; n += 1;
at = i + to.len(); (at, copied) = (end, end);
if !all { if !all {
break; break;
} }
} }
if n > 0 {
out.push_str(text.get(copied..).unwrap_or_default());
*text = out;
}
n n
} }
@@ -764,6 +771,49 @@ fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
/// The replacement as it ran before: edited in place for every match.
fn replace_word_in_place(text: &mut String, from: &str, to: &str, all: bool) -> usize {
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
let mut n = 0;
let mut at = 0;
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
let end = i + from.len();
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word);
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
if before || after {
at = end;
continue;
}
text.replace_range(i..end, to);
n += 1;
at = i + to.len();
if !all {
break;
}
}
n
}
#[test]
fn replace_word_matches_the_in_place_version() {
let cases = [
("a a b a", "a", "x", true),
("aa a. a", "a", "bb", true),
("a-a -a-", "-", "ab", true),
("foo. foo.", "foo.", "", true),
("it's it", "it", "so", true),
("x.x. x.", "x.", "y", true),
("a a a", "a", "b", false),
("none", "z", "q", true),
("é é", "é", "e", true),
];
for (text, from, to, all) in cases {
let (mut a, mut b) = (text.to_string(), text.to_string());
let (na, nb) = (replace_word(&mut a, from, to, all), replace_word_in_place(&mut b, from, to, all));
assert_eq!((na, a), (nb, b), "{text:?} {from:?} -> {to:?}");
}
}
use super::*; use super::*;
use serde_json::json; use serde_json::json;
@@ -812,6 +862,40 @@ mod tests {
assert_eq!(here["text"], "on first"); assert_eq!(here["text"], "on first");
} }
#[test]
fn malformed_comment_navigation_index_does_not_change_selection() {
let mut s = Session::with_new();
s.execute("comment.add", &json!({"text": "first"})).unwrap();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap();
s.execute("slide.new", &json!({})).unwrap();
s.execute("comment.add", &json!({"text": "second"})).unwrap();
s.execute("slide.go", &json!({"index": 0})).unwrap();
let shape = s.doc().unwrap().doc.slides.first().and_then(|slide| slide.shapes.first()).map(|shape| shape.id).unwrap();
s.doc_mut().unwrap().selection.shapes = vec![shape];
for (command, value) in [
("comment.next", json!(-1)),
("comment.next", json!(1.5)),
("comment.next", json!("bad")),
("comment.prev", json!(-1)),
("comment.prev", json!(1.5)),
("comment.prev", json!("bad")),
] {
let before = s.doc().unwrap().selection.clone();
let error = s.execute(command, &json!({"index": value})).unwrap_err();
assert!(error.to_string().contains("valid index"), "{command} {value}: {error}");
assert_eq!(s.doc().unwrap().selection, before, "{command} {value}");
}
let zero = s.execute("comment.next", &json!({"slide": 0, "index": 0})).unwrap();
assert_eq!(zero["slide"], 1);
assert_eq!(zero["index"], 0);
s.execute("slide.go", &json!({"index": 0})).unwrap();
let omitted = s.execute("comment.next", &json!({})).unwrap();
assert_eq!(omitted["slide"], 0);
assert_eq!(omitted["index"], 0);
}
#[test] #[test]
fn timestamps_are_iso_utc() { fn timestamps_are_iso_utc() {
assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z"); assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z");
+1 -1
View File
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Fill", "Shape Fill",
["Shape Format", "Shape Styles"], ["Shape Format", "Shape Styles"],
None, None,
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members", "{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool (false keeps linear and radial gradients upright on the slide; rectangular and path gradients turn with the shape when drawn)} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
has_selection, has_selection,
fill fill
), ),
+2 -1
View File
@@ -74,7 +74,8 @@ pub(super) fn change(s: &mut Session, p: &Value) -> Result<Value> {
/// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear), /// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear),
/// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits), /// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits),
/// rotateWithShape?}`. /// rotateWithShape?}`. The renderer keeps only linear and radial gradients upright when
/// `rotateWithShape` is false; rectangular and path gradients turn with the shape.
pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> { pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
let stops: Vec<GradientStop> = v let stops: Vec<GradientStop> = v
.get("stops")? .get("stops")?
+2 -1
View File
@@ -408,8 +408,9 @@ fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
}; };
s.select(|doc, sel| { s.select(|doc, sel| {
if add { if add {
let mut have: std::collections::HashSet<_> = sel.slides.iter().copied().collect();
for i in &ids { for i in &ids {
if !sel.slides.contains(i) { if have.insert(*i) {
sel.slides.push(*i); sel.slides.push(*i);
} }
} }
+112 -18
View File
@@ -121,9 +121,10 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
let cur_matched: HashSet<usize> = pairs.iter().map(|p| p.0).collect(); let cur_matched: HashSet<usize> = pairs.iter().map(|p| p.0).collect();
let rev_matched: HashSet<usize> = pairs.iter().map(|p| p.1).collect(); let rev_matched: HashSet<usize> = pairs.iter().map(|p| p.1).collect();
let mut out = vec![]; let mut out = vec![];
let mut media = Media::new(cur, rev);
for (i, j) in &pairs { for (i, j) in &pairs {
let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue }; let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue };
let details = slide_details(cur, a, rev, b); let details = slide_details(cur, a, rev, b, &mut media);
if !details.is_empty() { if !details.is_empty() {
out.push(Change { kind: ChangeKind::Changed, slide: *i, number: i.saturating_add(1), only_in: None, revised: Some(*j), details }); out.push(Change { kind: ChangeKind::Changed, slide: *i, number: i.saturating_add(1), only_in: None, revised: Some(*j), details });
} }
@@ -140,10 +141,18 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
}); });
} }
} }
// Each revised slide's open match (the first pair naming it).
let mut rev_to_cur: HashMap<usize, usize> = HashMap::new();
for p in &pairs {
rev_to_cur.entry(p.1).or_insert(p.0);
}
// After the open slide that matches the nearest matched revised slide before it.
let mut at = 0;
for (j, s) in rev.slides.iter().enumerate() { for (j, s) in rev.slides.iter().enumerate() {
if let Some(i) = rev_to_cur.get(&j) {
at = i + 1;
}
if !rev_matched.contains(&j) { if !rev_matched.contains(&j) {
// After the open slide that matches the revised slide before it.
let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0);
out.push(Change { out.push(Change {
kind: ChangeKind::Inserted, kind: ChangeKind::Inserted,
slide: at, slide: at,
@@ -174,8 +183,34 @@ fn shape_label(s: &Shape) -> String {
if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() } if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() }
} }
fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> { /// Both decks' media bytes by id, and which picture pairs were already compared: a deck can
p.media(id).map(|m| m.data.as_slice()) /// show one picture many times, and its bytes are compared once per pair.
struct Media<'a> {
cur: HashMap<MediaId, &'a [u8]>,
rev: HashMap<MediaId, &'a [u8]>,
same: HashMap<(MediaId, MediaId), bool>,
}
impl<'a> Media<'a> {
fn new(cur: &'a Presentation, rev: &'a Presentation) -> Self {
// The first item with an id wins, as in `Presentation::media`.
let index = |p: &'a Presentation| {
let mut m = HashMap::new();
for x in &p.media {
m.entry(x.id).or_insert(x.data.as_slice());
}
m
};
Media { cur: index(cur), rev: index(rev), same: HashMap::new() }
}
/// Whether the pictures (by media id, `None` for a shape that is not a picture) differ.
fn differ(&mut self, a: Option<MediaId>, b: Option<MediaId>) -> bool {
let (ba, bb) = (a.and_then(|x| self.cur.get(&x)), b.and_then(|y| self.rev.get(&y)));
match (a, b) {
(Some(x), Some(y)) if ba.is_some() && bb.is_some() => !*self.same.entry((x, y)).or_insert_with(|| ba == bb),
_ => ba.is_some() || bb.is_some(),
}
}
} }
/// A shape without its ids (shape and media), for comparing across decks. /// A shape without its ids (shape and media), for comparing across decks.
@@ -204,7 +239,7 @@ fn turn(ctx: Option<&Ctx>, s: &Shape) -> (f64, bool, bool) {
drawn_box(ctx, s).map_or((0.0, false, false), |x| (x.rot, x.flip_h, x.flip_v)) drawn_box(ctx, s).map_or((0.0, false, false), |x| (x.rot, x.flip_h, x.flip_v))
} }
fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentation, cb: Option<&Ctx>, b: &Shape, out: &mut Vec<String>) { fn shape_details(ca: Option<&Ctx>, a: &Shape, cb: Option<&Ctx>, b: &Shape, media: &mut Media, out: &mut Vec<String>) {
let label = shape_label(b); let label = shape_label(b);
let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default(); let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default();
let mut what = vec![]; let mut what = vec![];
@@ -234,11 +269,11 @@ fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentati
if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects { if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects {
what.push("formatting"); what.push("formatting");
} }
let pic = |p: &Presentation, s: &Shape| match &s.kind { let pic = |s: &Shape| match &s.kind {
ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()), ShapeKind::Picture { fill } => Some(fill.media),
_ => None, _ => None,
}; };
if pic(cp, a) != pic(rp, b) { if media.differ(pic(a), pic(b)) {
what.push("picture"); what.push("picture");
} }
// Anything the checks above do not cover (alt text, hidden, a link…) is reported too, also // Anything the checks above do not cover (alt text, hidden, a link…) is reported too, also
@@ -265,21 +300,32 @@ fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentati
} }
} }
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec<String> { fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide, media: &mut Media) -> Vec<String> {
let mut out = vec![]; let mut out = vec![];
let mut used: HashSet<usize> = HashSet::new(); let mut used: HashSet<usize> = HashSet::new();
let (ca, cb) = (Ctx::for_slide(cp, a), Ctx::for_slide(rp, b)); let (ca, cb) = (Ctx::for_slide(cp, a), Ctx::for_slide(rp, b));
// The revised shapes by id (first wins) and by name (in order, with a cursor past used ones).
let mut by_id: HashMap<ShapeId, usize> = HashMap::new();
let mut by_name: HashMap<&str, (Vec<usize>, usize)> = HashMap::new();
for (k, x) in b.shapes.iter().enumerate() {
by_id.entry(x.id).or_insert(k);
if !x.name.is_empty() {
by_name.entry(x.name.as_str()).or_default().0.push(k);
}
}
for sa in &a.shapes { for sa in &a.shapes {
let k = b let k = by_id.get(&sa.id).copied().or_else(|| {
.shapes let (ks, next) = by_name.get_mut(sa.name.as_str())?;
.iter() while ks.get(*next).is_some_and(|k| used.contains(k)) {
.position(|x| x.id == sa.id) *next += 1;
.or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty())); }
ks.get(*next).copied()
});
match k { match k {
Some(k) if !used.contains(&k) => { Some(k) if !used.contains(&k) => {
used.insert(k); used.insert(k);
if let Some(sb) = b.shapes.get(k) { if let Some(sb) = b.shapes.get(k) {
shape_details(cp, ca.as_ref(), sa, rp, cb.as_ref(), sb, &mut out); shape_details(ca.as_ref(), sa, cb.as_ref(), sb, media, &mut out);
} }
} }
_ => out.push(format!("{} deleted (only in the deck)", shape_label(sa))), _ => out.push(format!("{} deleted (only in the deck)", shape_label(sa))),
@@ -334,19 +380,34 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
let mut sl = slide.clone(); let mut sl = slide.clone();
// Media. // Media.
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new(); let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
// Both decks' media by id (the first item with an id wins, as in `Presentation::media`),
// looked up once per reference instead of searched.
let mut rev_at: HashMap<MediaId, usize> = HashMap::new();
for (i, m) in rev.media.iter().enumerate() {
rev_at.entry(m.id).or_insert(i);
}
let mut doc_at: HashMap<MediaId, usize> = HashMap::new();
for (i, m) in doc.media.iter().enumerate() {
doc_at.entry(m.id).or_insert(i);
}
for id in crate::media_refs::slide_media(&sl) { for id in crate::media_refs::slide_media(&sl) {
let Some(m) = rev.media(id) else { continue }; let Some(m) = rev_at.get(&id).and_then(|&i| rev.media.get(i)) else { continue };
match doc.media(id) { match doc_at.get(&id).and_then(|&i| doc.media.get(i)) {
Some(x) if x.data == m.data => {} Some(x) if x.data == m.data => {}
None => { None => {
// Keeps the revision's id, so later ids must start past it. // Keeps the revision's id, so later ids must start past it.
doc_at.insert(id, doc.media.len());
doc.media.push(m.clone()); doc.media.push(m.clone());
doc.next_id = doc.next_id.max(id.0); doc.next_id = doc.next_id.max(id.0);
} }
Some(_) => { Some(_) => {
let before = doc.media.len();
let new = doc let new = doc
.add_media(&m.name, &m.content_type, m.data.as_ref().clone()) .add_media(&m.name, &m.content_type, m.data.as_ref().clone())
.ok_or_else(|| crate::cmd::bad("review.accept", crate::cmd::NO_ID_SPACE))?; .ok_or_else(|| crate::cmd::bad("review.accept", crate::cmd::NO_ID_SPACE))?;
if doc.media.len() > before {
doc_at.entry(new).or_insert(before);
}
media_map.insert(id, new); media_map.insert(id, new);
} }
} }
@@ -519,6 +580,39 @@ mod tests {
assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing"); assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing");
} }
#[test]
fn many_inserted_slides_and_same_named_pictures_compare_by_index() {
use std::sync::Arc;
use deckcraft_model::style::PictureFill;
use deckcraft_model::{Shape, ShapeId, ShapeKind, SlideId, defaults};
// One slide of 3,000 pictures of one image, all named "Box"; the revision has the same
// pictures under other ids (matched by name, in order) and 3,000 slides inserted after it.
let mut cur = defaults::new_presentation(None);
let img = cur.add_media("a.png", "image/png", vec![7; 4096]).unwrap();
let mut rev = cur.clone();
let pic = |id: u32| Shape {
id: ShapeId(id),
name: "Box".into(),
xfrm: Some(deckcraft_model::Xfrm::new(10.0, 10.0, 50.0, 50.0)),
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
..Default::default()
};
Arc::make_mut(&mut cur.slides[0]).shapes = (0..3000).map(|k| pic(100_000 + k)).collect();
Arc::make_mut(&mut rev.slides[0]).shapes = (0..3000).map(|k| pic(200_000 + k)).collect();
let mut base = (*rev.slides[0]).clone();
base.shapes.clear();
for k in 0..3000 {
let mut s = base.clone();
s.id = SlideId(300_000 + k);
rev.slides.push(Arc::new(s));
}
let changes = super::diff(&cur, &rev, false);
assert_eq!(changes.len(), 3000, "only the inserted slides differ");
assert!(changes.iter().all(|c| c.kind == super::ChangeKind::Inserted && c.slide == 1));
}
/// A deck with one slide: a table whose cell has a picture fill, a picture background and a /// A deck with one slide: a table whose cell has a picture fill, a picture background and a
/// transition sound. `tag` starts the bytes of each. /// transition sound. `tag` starts the bytes of each.
fn media_deck(tag: &str) -> deckcraft_model::Presentation { fn media_deck(tag: &str) -> deckcraft_model::Presentation {
+14
View File
@@ -48,6 +48,8 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
// A short fixed-size name: appending to the target's name could exceed the file name limit. // A short fixed-size name: appending to the target's name could exceed the file name limit.
let tmp_name = format!(".deckcraft.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed)); let tmp_name = format!(".deckcraft.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
let tmp = target_path.with_file_name(tmp_name); let tmp = target_path.with_file_name(tmp_name);
// The sibling must pass the same check: when `path` is the write root itself it lands outside.
crate::file_access::check_write(&tmp.to_string_lossy())?;
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
Ok(f) => break (tmp, f), Ok(f) => break (tmp, f),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1, Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1,
@@ -135,4 +137,16 @@ mod tests {
let _ = std::fs::remove_file(&file); let _ = std::fs::remove_file(&file);
} }
#[test]
fn write_file_to_the_write_root_itself_creates_no_sibling_outside_it() {
let base = std::env::temp_dir().join(format!("deckcraft_io_root_test_{}", std::process::id()));
let root = base.join("root");
std::fs::create_dir_all(&root).unwrap();
let roots = crate::file_access::AutomationRoots::new(&[], std::slice::from_ref(&root)).unwrap().unwrap();
let res = crate::file_access::confine(Some(&roots), || write_file(root.to_str().unwrap(), b"x"));
assert!(res.unwrap_err().contains("outside the write root"));
assert_eq!(std::fs::read_dir(&base).unwrap().count(), 1, "only the root is there");
let _ = std::fs::remove_dir_all(&base);
}
} }
+42 -8
View File
@@ -700,15 +700,29 @@ pub struct Word {
pub repeated: Option<usize>, pub repeated: Option<usize>,
} }
/// Longest language tag a word is checked in. BCP 47 tags in documents are a few bytes; a longer
/// value from a file is no language we have a dictionary for, and copying it into every word of
/// a paragraph would multiply it.
const MAX_LANG_LEN: usize = 64;
/// The words of `p`, each with its run's language (`lang` on the run, else `default`). Words /// The words of `p`, each with its run's language (`lang` on the run, else `default`). Words
/// with digits are skipped; apostrophes inside a word belong to it; hyphenated words are checked /// with digits are skipped; apostrophes inside a word belong to it; hyphenated words are checked
/// part by part. /// part by part. A language over [`MAX_LANG_LEN`] counts as none.
pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Word> { pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Word> {
let mut chars: Vec<(char, usize)> = vec![]; let mut chars: Vec<(char, usize)> = vec![];
for (ri, r) in p.runs.iter().enumerate() { for (ri, r) in p.runs.iter().enumerate() {
chars.extend(r.content().chars().map(|c| (c, ri))); chars.extend(r.content().chars().map(|c| (c, ri)));
} }
let lang_of = |ri: usize| p.runs.get(ri).and_then(|r| r.props.lang.clone()).filter(|l| !l.is_empty()).unwrap_or_else(|| default.to_string()); let default = if default.len() > MAX_LANG_LEN { "" } else { default };
let lang_of = |ri: usize| {
p.runs
.get(ri)
.and_then(|r| r.props.lang.as_deref())
.filter(|l| !l.is_empty())
.map(|l| if l.len() > MAX_LANG_LEN { "" } else { l })
.unwrap_or(default)
.to_string()
};
// Combining marks (decomposed accents) belong to the letter before them. // Combining marks (decomposed accents) belong to the letter before them.
let inside = |c: char| c.is_alphanumeric() || deckcraft_model::words::is_combining_mark(c) || c == '\'' || c == '’' || c == '-'; let inside = |c: char| c.is_alphanumeric() || deckcraft_model::words::is_combining_mark(c) || c == '\'' || c == '’' || c == '-';
let mut out = vec![]; let mut out = vec![];
@@ -791,15 +805,18 @@ pub fn misspelt(p: &deckcraft_model::Paragraph, default: &str, ignore: &[String]
.chain(repeated(p, default)) .chain(repeated(p, default))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.into_iter() .into_iter()
.fold(vec![], |mut v: Vec<Word>, w| { .fold((vec![], HashMap::new()), |(mut v, mut at): (Vec<Word>, HashMap<usize, usize>), w| {
// A repeated misspelt word is reported once, as repeated. // A repeated misspelt word is reported once, as repeated.
if let Some(i) = v.iter().position(|x| x.start == w.start) { match at.get(&w.start).and_then(|&i| v.get_mut(i)) {
v[i] = w; Some(x) => *x = w,
} else { None => {
v.push(w); at.insert(w.start, v.len());
v.push(w);
}
} }
v (v, at)
}) })
.0
} }
/// Use `d` for `lang` from now on in this process (a dictionary the app loaded itself). /// Use `d` for `lang` from now on in this process (a dictionary the app loaded itself).
@@ -953,6 +970,23 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
assert_eq!(w, vec!["check", "done"]); assert_eq!(w, vec!["check", "done"]);
} }
#[test]
fn a_long_paragraph_with_a_huge_language_is_checked_in_linear_work() {
use deckcraft_model::{Paragraph, Run, RunProps};
// 100,000 copies of one word under a 1 MB language value: every word after the first is
// a repeat, and no word carries a copy of the value.
let lang = "x".repeat(1 << 20);
let p =
Paragraph { runs: vec![Run::with("the ".repeat(100_000), RunProps { lang: Some(lang), ..Default::default() })], ..Default::default() };
let words = paragraph_words(&p, "en-US");
assert_eq!(words.len(), 100_000);
assert!(words.iter().all(|w| w.lang.is_empty()));
let bad = misspelt(&p, "en-US", &[]);
assert_eq!(bad.len(), 99_999);
assert!(bad.iter().all(|w| w.repeated.is_some()));
assert!(paragraph_words(&Paragraph::new("word"), &"y".repeat(100)).iter().all(|w| w.lang.is_empty()));
}
#[test] #[test]
fn long_and_numeric_flags_and_latin1() { fn long_and_numeric_flags_and_latin1() {
let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n"); let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n");
+3
View File
@@ -4321,6 +4321,9 @@ fn shape_fill_documents_each_gradient_key_and_kind() {
let params = cmd::parse_params(find_command("shape.fill").unwrap().params).unwrap(); let params = cmd::parse_params(find_command("shape.fill").unwrap().params).unwrap();
let about = params.iter().find(|p| p.name == "gradient").unwrap().about; let about = params.iter().find(|p| p.name == "gradient").unwrap().about;
assert_eq!(cmd::param_keys(about).unwrap(), ["stops", "kind", "angle", "focus", "rotateWithShape"]); assert_eq!(cmd::param_keys(about).unwrap(), ["stops", "kind", "angle", "focus", "rotateWithShape"]);
// The renderer keeps only linear and radial gradients upright (`paint::fill_path`).
let rotate = cmd::parse_params(about).unwrap().into_iter().find(|p| p.name == "rotateWithShape").unwrap().about;
assert!(rotate.contains("rectangular and path gradients turn with the shape"), "{rotate}");
for kind in ["linear", "radial", "rectangular", "path"] { for kind in ["linear", "radial", "rectangular", "path"] {
assert!(about.contains(kind), "{kind}: {about}"); assert!(about.contains(kind), "{kind}: {about}");
let g = cmd::shape::gradient_param(&json!({ let g = cmd::shape::gradient_param(&json!({
+6 -1
View File
@@ -227,6 +227,9 @@ impl FontDb {
} }
} }
/// Most caret records read for one ligature glyph.
const MAX_LIGATURE_CARETS: usize = 64;
/// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the /// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the
/// glyph's origin (sorted ascending). Empty when the font has none for `gid`. /// glyph's origin (sorted ascending). Empty when the font has none for `gid`.
pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> { pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
@@ -236,9 +239,12 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
let Ok(cov) = list.coverage() else { return Vec::new() }; let Ok(cov) = list.coverage() else { return Vec::new() };
let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() }; let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() };
let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() }; let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() };
// The limit applies to the records read, not to the carets kept: a font can list thousands of
// records it then has to be read through for every ligature laid out.
let mut out: Vec<i32> = lig let mut out: Vec<i32> = lig
.caret_values() .caret_values()
.iter() .iter()
.take(MAX_LIGATURE_CARETS)
.filter_map(|cv| cv.ok()) .filter_map(|cv| cv.ok())
.filter_map(|cv| match cv { .filter_map(|cv| match cv {
skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32), skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32),
@@ -246,7 +252,6 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
// Format 2 is a contour point index; outlines aren't consulted here. // Format 2 is a contour point index; outlines aren't consulted here.
skrifa::raw::tables::gdef::CaretValue::Format2(_) => None, skrifa::raw::tables::gdef::CaretValue::Format2(_) => None,
}) })
.take(64)
.collect(); .collect();
out.sort_unstable(); out.sort_unstable();
out out
+149 -20
View File
@@ -368,7 +368,7 @@ struct CatalogEntry {
/// Process-wide font database. /// Process-wide font database.
pub struct FontDb { pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>, faces: RwLock<Vec<Arc<FontFace>>>,
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>, outlines: Mutex<OutlineCache>,
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
catalog: RwLock<Vec<CatalogEntry>>, catalog: RwLock<Vec<CatalogEntry>>,
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]). /// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
@@ -439,6 +439,38 @@ const SYSTEM_FALLBACKS: &[&str] = &[
static NEXT_ID: AtomicU32 = AtomicU32::new(1); static NEXT_ID: AtomicU32 = AtomicU32::new(1);
const OUTLINE_CACHE_MAX: usize = 50_000; const OUTLINE_CACHE_MAX: usize = 50_000;
/// Most path elements one glyph outline may expand to. Composite and `VARC` glyphs can draw a
/// shared outline many times over; a glyph past this draws nothing rather than holding megabytes.
const MAX_OUTLINE_ELEMENTS: usize = 1 << 15;
/// Most path elements the outline cache holds across all glyphs (about 230 MB at most) before it
/// starts over: distinct glyph ids can each expand one dense outline.
const OUTLINE_CACHE_MAX_ELEMENTS: usize = 1 << 22;
/// Glyph outlines by (face, glyph), with the path elements they hold.
#[derive(Default)]
struct OutlineCache {
paths: HashMap<(u32, u32), Arc<BezPath>>,
elements: usize,
}
impl OutlineCache {
fn get(&self, key: &(u32, u32)) -> Option<Arc<BezPath>> {
self.paths.get(key).cloned()
}
fn insert(&mut self, key: (u32, u32), path: Arc<BezPath>) {
let n = path.elements().len();
if let Some(old) = self.paths.remove(&key) {
self.elements = self.elements.saturating_sub(old.elements().len());
}
if self.paths.len() >= OUTLINE_CACHE_MAX || self.elements.saturating_add(n) > OUTLINE_CACHE_MAX_ELEMENTS {
self.paths.clear();
self.elements = 0;
}
self.paths.insert(key, path);
self.elements = self.elements.saturating_add(n);
}
}
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> { fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
@@ -694,6 +726,10 @@ const MAX_COLLECTION_FACES: u32 = 256;
const MAX_COLLECTION_ENTRIES_SCANNED: u32 = 16_384; const MAX_COLLECTION_ENTRIES_SCANNED: u32 = 16_384;
/// Maximum named instances read from a single face. /// Maximum named instances read from a single face.
const MAX_NAMED_INSTANCES_PER_FACE: usize = 1024; const MAX_NAMED_INSTANCES_PER_FACE: usize = 1024;
/// Maximum faces (named instances counted separately) enumerated from one font file, across all
/// of a collection's faces: the per-collection and per-face limits multiply, and each face found
/// is then built with its own shaper.
const MAX_ENUMERATED_FACES: usize = 4096;
fn distinct_face_indices(entries: impl IntoIterator<Item = (u32, u32)>) -> Vec<u32> { fn distinct_face_indices(entries: impl IntoIterator<Item = (u32, u32)>) -> Vec<u32> {
let mut offsets = std::collections::HashSet::new(); let mut offsets = std::collections::HashSet::new();
@@ -730,21 +766,30 @@ fn enumerate_faces(data: &[u8]) -> Vec<Found> {
Err(_) => Vec::new(), Err(_) => Vec::new(),
}; };
let mut out = Vec::new(); let mut out = Vec::new();
// Named instances read and plain faces kept, across the whole file.
let mut budget = MAX_ENUMERATED_FACES;
for i in indices { for i in indices {
if budget == 0 {
break;
}
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue }; let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
let Some((family, style)) = face_names(&f) else { continue }; let Some((family, style)) = face_names(&f) else { continue };
let axes = f.axes(); let axes = f.axes();
let mut named = Vec::new(); let mut named = Vec::new();
let mut seen = std::collections::HashSet::new();
let instances = f.named_instances(); let instances = f.named_instances();
for ni in instances.iter().take(capped_named_instance_count(instances.len())) { for ni in instances.iter().take(capped_named_instance_count(instances.len())) {
let Some(left) = budget.checked_sub(1) else { break };
budget = left;
let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue }; let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue };
if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) { if !seen.insert(style.to_ascii_lowercase()) {
continue; continue;
} }
let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect(); let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect();
named.push((i, style, coords)); named.push((i, style, coords));
} }
if named.is_empty() { if named.is_empty() {
budget = budget.saturating_sub(1);
out.push((i, family, style, Vec::new())); out.push((i, family, style, Vec::new()));
} else { } else {
out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c))); out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c)));
@@ -905,7 +950,7 @@ impl FontDb {
} }
Self { Self {
faces: RwLock::new(faces), faces: RwLock::new(faces),
outlines: Mutex::new(HashMap::new()), outlines: Mutex::new(OutlineCache::default()),
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
catalog: RwLock::new(Vec::new()), catalog: RwLock::new(Vec::new()),
font_dirs, font_dirs,
@@ -1215,9 +1260,8 @@ impl FontDb {
fn instance(&self, family: &str, style: &str) -> Option<Arc<FontFace>> { fn instance(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
let open = style.find('{')?; let open = style.find('{')?;
let base = self.find(family, style[..open].trim())?; let base = self.find(family, style[..open].trim())?;
if base.skrifa().is_none_or(|f| f.axes().is_empty()) { let Some(font) = base.skrifa().filter(|f| !f.axes().is_empty()) else { return Some(base) };
return Some(base); let axis_tags: Vec<[u8; 4]> = font.axes().iter().map(|a| a.tag().to_be_bytes()).collect();
}
let mut coords = base.coords.clone(); let mut coords = base.coords.clone();
for kv in style[open + 1..].trim_end_matches('}').split(',') { for kv in style[open + 1..].trim_end_matches('}').split(',') {
let (k, v) = kv.split_once(':')?; let (k, v) = kv.split_once(':')?;
@@ -1227,6 +1271,11 @@ impl FontDb {
return None; return None;
} }
let tag = [k[0], k[1], k[2], k[3]]; let tag = [k[0], k[1], k[2], k[3]];
// A tag the font has no axis for changes nothing; keeping it would grow the list with
// every distinct tag in the style text.
if !axis_tags.contains(&tag) {
continue;
}
match coords.iter_mut().find(|(t, _)| *t == tag) { match coords.iter_mut().find(|(t, _)| *t == tag) {
Some(c) => c.1 = v, Some(c) => c.1 = v,
None => coords.push((tag, v)), None => coords.push((tag, v)),
@@ -1513,41 +1562,72 @@ impl FontDb {
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> { pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id, gid); let key = (face.id, gid);
if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return p.clone(); return p;
} }
let mut pen = FlipPen(BezPath::new()); let mut pen = FlipPen::new(MAX_OUTLINE_ELEMENTS);
if let Some(f) = face.skrifa() if let Some(f) = face.skrifa()
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid)) && let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
{ {
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen); let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
} }
let p = Arc::new(pen.0); let p = Arc::new(pen.finish());
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner); self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone());
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
cache.insert(key, p.clone());
p p
} }
} }
struct FlipPen(BezPath); /// Collects a glyph outline, y flipped, up to `limit` path elements. Past the limit it ignores
/// what follows and [`FlipPen::finish`] gives an empty path: a cut-off outline would fill wrongly.
struct FlipPen {
path: BezPath,
limit: usize,
over: bool,
}
impl FlipPen {
fn new(limit: usize) -> Self {
FlipPen { path: BezPath::new(), limit, over: false }
}
/// Room for one more element?
fn room(&mut self) -> bool {
if !self.over && self.path.elements().len() >= self.limit {
self.over = true;
self.path = BezPath::new();
}
!self.over
}
fn finish(self) -> BezPath {
if self.over { BezPath::new() } else { self.path }
}
}
impl OutlinePen for FlipPen { impl OutlinePen for FlipPen {
fn move_to(&mut self, x: f32, y: f32) { fn move_to(&mut self, x: f32, y: f32) {
self.0.move_to((x as f64, -y as f64)); if self.room() {
self.path.move_to((x as f64, -y as f64));
}
} }
fn line_to(&mut self, x: f32, y: f32) { fn line_to(&mut self, x: f32, y: f32) {
self.0.line_to((x as f64, -y as f64)); if self.room() {
self.path.line_to((x as f64, -y as f64));
}
} }
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64)); if self.room() {
self.path.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
}
} }
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64)); if self.room() {
self.path.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
}
} }
fn close(&mut self) { fn close(&mut self) {
self.0.close_path(); if self.room() {
self.path.close_path();
}
} }
} }
@@ -1575,6 +1655,55 @@ mod font_dir_tests {
assert_eq!(capped_named_instance_count(MAX_NAMED_INSTANCES_PER_FACE + 1), MAX_NAMED_INSTANCES_PER_FACE); assert_eq!(capped_named_instance_count(MAX_NAMED_INSTANCES_PER_FACE + 1), MAX_NAMED_INSTANCES_PER_FACE);
} }
#[test]
fn faces_of_one_file_are_bounded_across_the_collection() {
// The per-collection and per-face limits together would allow far more.
assert!((MAX_COLLECTION_FACES as usize) * MAX_NAMED_INSTANCES_PER_FACE > 16 * MAX_ENUMERATED_FACES);
for data in bundled() {
assert!(enumerate_faces(data).len() <= MAX_ENUMERATED_FACES);
}
}
#[test]
fn an_outline_past_the_element_limit_draws_nothing() {
let mut pen = FlipPen::new(8);
pen.move_to(0.0, 0.0);
pen.line_to(1.0, 1.0);
pen.close();
assert_eq!(pen.finish().elements().len(), 3);
// A shared outline drawn over and over (composite and VARC glyphs).
let mut pen = FlipPen::new(8);
for i in 0..1_000_000 {
pen.move_to(0.0, 0.0);
pen.line_to(i as f32, 1.0);
pen.close();
}
assert_eq!(pen.path.elements().len(), 0);
assert!(pen.finish().elements().is_empty());
}
#[test]
fn outline_cache_is_bounded_by_elements() {
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
for i in 1..MAX_OUTLINE_ELEMENTS {
path.line_to((i as f64, 0.0));
}
let path = Arc::new(path);
let mut cache = OutlineCache::default();
let glyphs = 4 * OUTLINE_CACHE_MAX_ELEMENTS / MAX_OUTLINE_ELEMENTS;
for gid in 0..glyphs as u32 {
cache.insert((1, gid), path.clone());
assert!(cache.elements <= OUTLINE_CACHE_MAX_ELEMENTS);
assert_eq!(cache.elements, cache.paths.values().map(|p| p.elements().len()).sum::<usize>());
}
assert!(cache.get(&(1, glyphs as u32 - 1)).is_some());
// Replacing an entry does not count it twice.
let before = cache.elements;
cache.insert((1, glyphs as u32 - 1), path.clone());
assert_eq!(cache.elements, before);
}
#[test] #[test]
fn xdg_data_dirs_contribute_their_fonts_folders() { fn xdg_data_dirs_contribute_their_fonts_folders() {
let home = Path::new("/home/ann"); let home = Path::new("/home/ann");
+5
View File
@@ -247,6 +247,11 @@ pub fn repair(p: &mut Presentation) {
if !(p.notes_size.width.is_finite() && p.notes_size.width > 0.0 && p.notes_size.height.is_finite() && p.notes_size.height > 0.0) { if !(p.notes_size.width.is_finite() && p.notes_size.width > 0.0 && p.notes_size.height.is_finite() && p.notes_size.height > 0.0) {
p.notes_size = Presentation::default().notes_size; p.notes_size = Presentation::default().notes_size;
} }
// A notes page is bounded like a slide: canvas and handout drawing scale with it.
p.notes_size = deckcraft_model::Size::new(
p.notes_size.width.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end()),
p.notes_size.height.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end()),
);
sane_numbers(p); sane_numbers(p);
} }
+8 -1
View File
@@ -299,7 +299,8 @@ impl Outline<'_> {
/// A one-character control symbol (`\\`, `\{`, `\~`, ...). /// A one-character control symbol (`\\`, `\{`, `\~`, ...).
fn symbol(&mut self, n: u8) { fn symbol(&mut self, n: u8) {
self.i += 1; let c = self.rtf.get(self.i..).and_then(|r| r.chars().next());
self.i = advance(self.rtf, self.i, 1);
if self.st.skip { if self.st.skip {
return; return;
} }
@@ -308,6 +309,7 @@ impl Outline<'_> {
b'~' => self.line.push('\u{a0}'), b'~' => self.line.push('\u{a0}'),
b'_' => self.line.push('\u{2011}'), b'_' => self.line.push('\u{2011}'),
b'\n' | b'\r' => self.flush(), b'\n' | b'\r' => self.flush(),
_ if !n.is_ascii() => self.line.extend(c),
_ => {} _ => {}
} }
} }
@@ -419,6 +421,11 @@ mod tests {
assert_eq!(rtf_to_outline(r"{\rtf1 x\u-70000?y\par}"), "x\u{ee90}y\n"); assert_eq!(rtf_to_outline(r"{\rtf1 x\u-70000?y\par}"), "x\u{ee90}y\n");
} }
#[test]
fn multibyte_control_symbol_keeps_character_and_following_text() {
assert_eq!(rtf_to_outline("{\\rtf1 A\\éB\\par}"), "AéB\n");
}
/// `\\_` is RTF's non-breaking hyphen: a visible character, not markup. /// `\\_` is RTF's non-breaking hyphen: a visible character, not markup.
#[test] #[test]
fn non_breaking_hyphen_is_kept() { fn non_breaking_hyphen_is_kept() {
+7
View File
@@ -451,6 +451,13 @@ fn degenerate_slide_sizes_are_repaired() {
} }
} }
#[test]
fn repair_bounds_the_notes_page_size() {
let mut p = Presentation { notes_size: deckcraft_model::Size::new(1e12, 0.01), ..Default::default() };
repair(&mut p);
assert_eq!((p.notes_size.width, p.notes_size.height), (4032.0, 72.0));
}
/// A file whose slide size is null, a string or missing a side (written by hand or by /// A file whose slide size is null, a string or missing a side (written by hand or by
/// another tool; NaN is also saved as null) opens with the default size rather than failing /// another tool; NaN is also saved as null) opens with the default size rather than failing
/// as damaged. /// as damaged.
+31 -2
View File
@@ -126,6 +126,21 @@ fn split_curve(c: &[Point], n: usize, out: &mut Vec<Point>) {
out.extend([p1, p2, p3]); out.extend([p1, p2, p3]);
} }
/// A piece length ordered totally, so it can sit in a heap.
#[derive(PartialEq)]
struct Share(f64);
impl Eq for Share {}
impl PartialOrd for Share {
fn partial_cmp(&self, o: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(o))
}
}
impl Ord for Share {
fn cmp(&self, o: &Self) -> std::cmp::Ordering {
self.0.total_cmp(&o.0)
}
}
/// Brings a contour to `n` curves. Unlike manim (which splits every curve equally often), the extra /// Brings a contour to `n` curves. Unlike manim (which splits every curve equally often), the extra
/// splits go to the longest pieces, so a long side gets more points than a short one. /// splits go to the longest pieces, so a long side gets more points than a short one.
fn refine(c: &Contour, n: usize) -> Contour { fn refine(c: &Contour, n: usize) -> Contour {
@@ -136,11 +151,15 @@ fn refine(c: &Contour, n: usize) -> Contour {
let curves: Vec<&[Point]> = (0..have).filter_map(|k| c.pts.get(3 * k..3 * k + 4)).collect(); let curves: Vec<&[Point]> = (0..have).filter_map(|k| c.pts.get(3 * k..3 * k + 4)).collect();
let len: Vec<f64> = curves.iter().map(|q| curve_length(q) + 1e-9).collect(); let len: Vec<f64> = curves.iter().map(|q| curve_length(q) + 1e-9).collect();
let mut parts = vec![1usize; curves.len()]; let mut parts = vec![1usize; curves.len()];
// The curve with the longest piece gets the next split (the lowest index on a tie); a heap keeps
// this O(n log n) instead of rescanning every curve for each split.
let mut heap: std::collections::BinaryHeap<(Share, std::cmp::Reverse<usize>)> =
len.iter().enumerate().map(|(k, l)| (Share(*l), std::cmp::Reverse(k))).collect();
for _ in 0..n - have { for _ in 0..n - have {
let share = |k: usize| len.get(k).copied().unwrap_or(0.0) / parts.get(k).copied().unwrap_or(1) as f64; let Some((_, std::cmp::Reverse(best))) = heap.pop() else { break };
let best = (1..curves.len()).fold(0, |b, k| if share(k) > share(b) { k } else { b });
if let Some(p) = parts.get_mut(best) { if let Some(p) = parts.get_mut(best) {
*p += 1; *p += 1;
heap.push((Share(len.get(best).copied().unwrap_or(0.0) / *p as f64), std::cmp::Reverse(best)));
} }
} }
let mut out = Vec::with_capacity(1 + 3 * n); let mut out = Vec::with_capacity(1 + 3 * n);
@@ -420,6 +439,16 @@ mod tests {
assert_eq!(contours(&to_path(&cs)), cs); assert_eq!(contours(&to_path(&cs)), cs);
} }
#[test]
fn refine_many_curves_finishes_at_the_asked_count() {
let mut c = Contour { closed: false, pts: vec![Point::ZERO] };
for i in 1..=20_000 {
line_to(&mut c.pts, Point::new(f64::from(i), f64::from(i % 7)));
}
let r = refine(&c, 40_000);
assert_eq!(r.curves(), 40_000);
}
#[test] #[test]
fn refine_splits_the_longest_curves_and_keeps_the_shape() { fn refine_splits_the_longest_curves_and_keeps_the_shape() {
let c = Contour { closed: false, pts: vec![Point::ZERO, Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)] }; let c = Contour { closed: false, pts: vec![Point::ZERO, Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)] };
+19 -9
View File
@@ -928,11 +928,25 @@ impl Warp {
k_fill = 1.0; k_fill = 1.0;
} }
zoom = zoom.max(k_fill); zoom = zoom.max(k_fill);
// Per band, measured once: its widest line and its first and last lines' baselines.
let mut stats: Vec<Option<(f64, f64, f64)>> = vec![None; nb];
for (l, &b) in lines.iter().zip(&band_of) {
let w = l.extent.1 - l.extent.0;
if let Some(slot) = stats.get_mut(b) {
match slot {
Some((widest, _, last)) => {
*widest = widest.max(w);
*last = l.ref_y;
}
None => *slot = Some((0.0f64.max(w), l.ref_y, l.ref_y)),
}
}
}
let mut maps = Vec::with_capacity(n); let mut maps = Vec::with_capacity(n);
let mut offsets = Vec::new(); let mut offsets = Vec::new();
for (i, l) in lines.iter().enumerate() { for (i, l) in lines.iter().enumerate() {
let b = band_of.get(i).copied().unwrap_or(0); let b = band_of.get(i).copied().unwrap_or(0);
let in_band = |j: &usize| band_of.get(*j) == Some(&b); let band = stats.get(b).copied().flatten();
match self.bands.get(b) { match self.bands.get(b) {
Some(Band::Fill { .. }) | None => { Some(Band::Fill { .. }) | None => {
let (y0, y1) = band_y.get(b).copied().flatten().unwrap_or((0.0, 1.0)); let (y0, y1) = band_y.get(b).copied().flatten().unwrap_or((0.0, 1.0));
@@ -941,16 +955,12 @@ impl Warp {
} }
Some(Band::Follow { path, anchor }) => { Some(Band::Follow { path, anchor }) => {
let len = path.length(); let len = path.length();
let widest = (0..n).filter(in_band).filter_map(|j| lines.get(j)).map(|l| l.extent.1 - l.extent.0).fold(0.0, f64::max); let widest = band.map_or(0.0, |s| s.0);
let k = if widest > len && widest > 1e-9 { len / widest } else { 1.0 }; let k = if widest > len && widest > 1e-9 { len / widest } else { 1.0 };
let members: Vec<&WarpLine> = (0..n).filter(in_band).filter_map(|j| lines.get(j)).collect();
let ref_y = match anchor { let ref_y = match anchor {
LineAnchor::Last => members.last().map(|l| l.ref_y), LineAnchor::Last => band.map(|s| s.2),
LineAnchor::First => members.first().map(|l| l.ref_y), LineAnchor::First => band.map(|s| s.1),
LineAnchor::Middle => match (members.first(), members.last()) { LineAnchor::Middle => band.map(|s| (s.1 + s.2) / 2.0),
(Some(a), Some(z)) => Some((a.ref_y + z.ref_y) / 2.0),
_ => None,
},
} }
.unwrap_or(l.ref_y); .unwrap_or(l.ref_y);
// Lines off the guide run on a parallel curve at their natural length. // Lines off the guide run on a parallel curve at their natural length.
+17
View File
@@ -319,3 +319,20 @@ fn arch_and_inflate_handles_follow_the_pointer() {
assert!(more > adjust_defaults("textInflate")[0], "{more}"); assert!(more > adjust_defaults("textInflate")[0], "{more}");
assert!((more - 20_000.0).abs() < 1.0, "{more}"); assert!((more - 20_000.0).abs() < 1.0, "{more}");
} }
#[test]
fn many_lines_on_a_follow_band_are_mapped_from_one_measurement() {
// 50,000 lines on an arch: each line's scale and anchor come from the band measured once.
let w = warp("textArchUp", &[], rect()).unwrap();
let inner = Rect::new(7.2, 3.6, W - 7.2, H - 3.6);
let lines: Vec<WarpLine> = (0..50_000)
.map(|i| {
let y = i as f64 * 0.01;
WarpLine { ink: Some(Rect::new(190.0, y, 230.0, y + 1.0)), extent: (190.0, 230.0), ref_y: y + 1.0 }
})
.collect();
let m = w.mapper(&lines, inner);
// The last line sits on the arc, as with two lines.
let last = lines.len() - 1;
assert!(m.map(last, Point::new(210.0, lines[last].ref_y)).y.abs() < 0.05);
}
+3 -1
View File
@@ -326,7 +326,9 @@ impl SliceHeader {
/// entry_point_offset list (tiles / wavefront substreams). /// entry_point_offset list (tiles / wavefront substreams).
fn parse_entry_points(&mut self, r: &mut BitReader, pps: &Pps, sps: &Sps) -> Result<()> { fn parse_entry_points(&mut self, r: &mut BitReader, pps: &Pps, sps: &Sps) -> Result<()> {
self.entry_points.clear(); // A fresh list, not `clear()`: a dependent segment starts as a copy of its independent
// segment's header, and clearing would keep that copy's allocation for every segment.
self.entry_points = Vec::new();
if pps.tiles_enabled || pps.entropy_coding_sync { if pps.tiles_enabled || pps.entropy_coding_sync {
let n = r.read_ue()?; let n = r.read_ue()?;
ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range"); ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range");
+89 -34
View File
@@ -758,17 +758,20 @@ impl Chart {
} }
fn stacked_value_range(&self, n: usize) -> (f64, f64) { fn stacked_value_range(&self, n: usize) -> (f64, f64) {
let mut lo: f64 = 0.0; // Sums per category, from the values the series hold (an absent one adds nothing).
let mut hi: f64 = 0.0; let (mut pos, mut neg) = (vec![0.0f64; n], vec![0.0f64; n]);
for i in 0..n { for s in &self.series {
let (mut p, mut m) = (0.0, 0.0); for (i, v) in s.values.iter().enumerate().take(n) {
for s in &self.series { let v = v.unwrap_or(0.0);
let v = s.values.get(i).copied().flatten().unwrap_or(0.0); if v >= 0.0 {
if v >= 0.0 { p += v } else { m += v } pos[i] += v;
} else {
neg[i] += v;
}
} }
hi = hi.max(p);
lo = lo.min(m);
} }
let hi = pos.iter().fold(0.0f64, |a, &p| a.max(p));
let lo = neg.iter().fold(0.0f64, |a, &m| a.min(m));
(lo, hi) (lo, hi)
} }
@@ -1348,40 +1351,50 @@ impl Chart {
/// The box chart's groups (distinct category labels in order of appearance; one unnamed /// The box chart's groups (distinct category labels in order of appearance; one unnamed
/// group without categories) and, per series, each group's summary (None when it has no /// group without categories) and, per series, each group's summary (None when it has no
/// points). /// points).
///
/// Each value is put in its group's bucket once (groups are found by name in a map), and the
/// series x groups result is held to [`MAX_BOX_CELLS`]: groups past it are left out.
pub fn box_groups(&self) -> (Vec<String>, Vec<Vec<Option<BoxStats>>>) { pub fn box_groups(&self) -> (Vec<String>, Vec<Vec<Option<BoxStats>>>) {
let max_groups = (MAX_BOX_CELLS / self.series.len().max(1)).max(1);
let mut groups: Vec<String> = Vec::new(); let mut groups: Vec<String> = Vec::new();
let mut index: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for c in &self.categories { for c in &self.categories {
if !groups.contains(c) { if !index.contains_key(c.as_str()) && groups.len() < max_groups {
index.insert(c, groups.len());
groups.push(c.clone()); groups.push(c.clone());
} }
} }
if groups.is_empty() { if groups.is_empty() {
groups.push(String::new()); groups.push(String::new());
index.insert("", 0);
} }
// A value past the categories belongs to the unnamed group, when there is one.
let past_end = index.get("").copied();
let stats = self let stats = self
.series .series
.iter() .iter()
.map(|s| { .map(|s| {
groups let mut buckets: Vec<Vec<f64>> = vec![Vec::new(); groups.len()];
.iter() for (i, v) in s.values.iter().enumerate() {
.map(|g| { let Some(v) = v.filter(|v| v.is_finite()) else { continue };
let pts: Vec<f64> = s let g = match self.categories.get(i) {
.values Some(c) => index.get(c.as_str()).copied(),
.iter() None => past_end,
.enumerate() };
.filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g)) if let Some(b) = g.and_then(|g| buckets.get_mut(g)) {
.filter_map(|(_, v)| *v) b.push(v);
.filter(|v| v.is_finite()) }
.collect(); }
box_stats_by(pts, self.inclusive_quartiles) buckets.into_iter().map(|pts| box_stats_by(pts, self.inclusive_quartiles)).collect()
})
.collect()
}) })
.collect(); .collect();
(groups, stats) (groups, stats)
} }
} }
/// Most series x group summaries [`Chart::box_groups`] makes.
pub const MAX_BOX_CELLS: usize = 1_000_000;
/// Widens the (min, max) extent `ext` to include `v`. /// Widens the (min, max) extent `ext` to include `v`.
fn widen(ext: &mut (f64, f64), v: f64) { fn widen(ext: &mut (f64, f64), v: f64) {
ext.0 = ext.0.min(v); ext.0 = ext.0.min(v);
@@ -1454,33 +1467,41 @@ impl Chart {
let n = self.categories.len().max(s.values.len()); let n = self.categories.len().max(s.values.len());
let levels: Vec<&Vec<String>> = self.category_levels.iter().take(MAX_LEVELS).collect(); let levels: Vec<&Vec<String>> = self.category_levels.iter().take(MAX_LEVELS).collect();
// Each leaf's path from the top level down, blanks filled from the row above. // Each leaf's path from the top level down, blanks filled from the row above.
let mut last: Vec<String> = vec![String::new(); levels.len()]; // Labels are shared, not copied: an outer label given once stands for every leaf under it.
let paths: Vec<Vec<String>> = (0..n) use std::rc::Rc;
let empty: Rc<str> = Rc::from("");
let mut last: Vec<Rc<str>> = vec![empty.clone(); levels.len()];
let paths: Vec<Vec<Rc<str>>> = (0..n)
.map(|i| { .map(|i| {
let mut path = Vec::with_capacity(levels.len() + 1); let mut path = Vec::with_capacity(levels.len() + 1);
for (d, lv) in levels.iter().enumerate().rev() { for (d, lv) in levels.iter().enumerate().rev() {
let l = lv.get(i).map(|x| x.trim()).unwrap_or(""); let l = lv.get(i).map(|x| x.trim()).unwrap_or("");
if !l.is_empty() { if !l.is_empty() {
last[d] = l.to_string(); last[d] = Rc::from(l);
// A new outer label starts fresh inner groups. // A new outer label starts fresh inner groups.
for inner in last.iter_mut().take(d) { for inner in last.iter_mut().take(d) {
inner.clear(); *inner = empty.clone();
} }
} }
path.push(last[d].clone()); path.push(last[d].clone());
} }
path.push(self.categories.get(i).cloned().unwrap_or_default()); path.push(self.categories.get(i).map_or_else(|| empty.clone(), |c| Rc::from(c.as_str())));
path path
}) })
.collect(); .collect();
let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0); let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0);
fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec<TreeNode> { fn build(rows: &[(usize, &[std::rc::Rc<str>])], value: &dyn Fn(usize) -> f64) -> Vec<TreeNode> {
let mut out: Vec<TreeNode> = Vec::new(); let mut out: Vec<TreeNode> = Vec::new();
let mut i = 0; let mut i = 0;
while i < rows.len() { while i < rows.len() {
let (idx, path) = rows[i]; let (idx, path) = rows[i];
if path.len() <= 1 { if path.len() <= 1 {
out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] }); out.push(TreeNode {
name: path.first().map(|p| p.to_string()).unwrap_or_default(),
value: value(idx),
leaf: Some(idx),
children: vec![],
});
i += 1; i += 1;
continue; continue;
} }
@@ -1489,14 +1510,14 @@ impl Chart {
while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head { while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head {
j += 1; j += 1;
} }
let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect(); let sub: Vec<(usize, &[std::rc::Rc<str>])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect();
let children = build(&sub, value); let children = build(&sub, value);
out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children }); out.push(TreeNode { name: head.to_string(), value: children.iter().map(|c| c.value).sum(), leaf: None, children });
i = j; i = j;
} }
out out
} }
let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect(); let rows: Vec<(usize, &[Rc<str>])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect();
build(&rows, &value) build(&rows, &value)
} }
} }
@@ -1607,6 +1628,40 @@ mod chartex_tests {
assert!(c.category_tree().iter().all(|n| n.leaf.is_some())); assert!(c.category_tree().iter().all(|n| n.leaf.is_some()));
} }
#[test]
fn large_box_hierarchy_and_stacked_charts_take_linear_work() {
// 50,000 distinct categories in one series: grouped through a map, one pass.
let n = 50_000;
let mut c = Chart::sample(ChartType::BoxWhisker);
c.series.truncate(1);
c.categories = (0..n).map(|i| format!("g{i}")).collect();
c.series[0].values = (0..n).map(|i| Some(i as f64)).collect();
let (groups, stats) = c.box_groups();
assert_eq!((groups.len(), stats[0].len()), (n, n));
assert!(stats[0].iter().all(Option::is_some));
// 2,000 series cap the groups so the result stays within MAX_BOX_CELLS.
c.series = vec![c.series[0].clone(); 2000];
let (groups, stats) = c.box_groups();
assert_eq!(groups.len(), MAX_BOX_CELLS / 2000);
assert!(stats.iter().all(|s| s.len() == groups.len()));
// One long outer label over 50,000 leaves is shared, not copied per leaf.
let mut t = Chart::sample(ChartType::Treemap);
t.series.truncate(1);
t.categories = (0..n).map(|i| format!("c{i}")).collect();
t.series[0].values = (0..n).map(|_| Some(1.0)).collect();
let mut outer = vec![String::new(); n];
outer[0] = "o".repeat(1 << 16);
t.category_levels = vec![outer];
let tree = t.category_tree();
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].children.len(), n);
// Stacked sums over many sparse series.
let mut st = Chart::sample(ChartType::StackedBar);
st.categories = (0..n).map(|i| i.to_string()).collect();
st.series = (0..2000).map(|k| Series { values: vec![Some(1.0); if k == 0 { n } else { 1 }], ..Default::default() }).collect();
assert!(st.value_range().1 >= 2000.0, "{:?}", st.value_range());
}
#[test] #[test]
fn box_groups_follow_category_labels() { fn box_groups_follow_category_labels() {
let c = Chart::sample(ChartType::BoxWhisker); let c = Chart::sample(ChartType::BoxWhisker);
+128 -21
View File
@@ -78,45 +78,57 @@ pub fn insert(body: &mut TextBody, pos: Pos, text: &str, props: Option<RunProps>
if body.paragraphs.is_empty() { if body.paragraphs.is_empty() {
body.paragraphs.push(Paragraph::default()); body.paragraphs.push(Paragraph::default());
} }
let (mut pi, mut ch) = clamp(body, pos); let (pi0, mut ch) = clamp(body, pos);
let props = props.unwrap_or_else(|| props_at(body, (pi, ch))); let props = props.unwrap_or_else(|| props_at(body, (pi0, ch)));
let text = text.replace("\r\n", "\n").replace('\r', "\n"); let text = text.replace("\r\n", "\n").replace('\r', "\n");
// Work on the paragraph being edited apart from the body, so each new paragraph is pushed to a
// list and the body's vector is spliced once instead of shifted for every `\n`.
let Some(slot) = body.paragraphs.get_mut(pi0) else { return (pi0, ch) };
let mut cur = std::mem::take(slot);
let mut done: Vec<Paragraph> = Vec::new();
let mut first = true; let mut first = true;
for part in text.split('\n') { for part in text.split('\n') {
if !first { if !first {
// Split paragraph pi at ch. // Split the current paragraph at ch.
let Some(p) = body.paragraphs.get_mut(pi) else { break }; let idx = split_at(&mut cur, ch);
let idx = split_at(p, ch); let tail: Vec<Run> = cur.runs.drain(idx..).collect();
let tail: Vec<Run> = p.runs.drain(idx..).collect(); let newp = Paragraph {
let newp = level: cur.level,
Paragraph { level: p.level, props: p.props.clone(), runs: tail, def_props: p.def_props.clone(), end_props: p.end_props.clone() }; props: cur.props.clone(),
p.end_props = props.clone(); runs: tail,
body.paragraphs.insert(pi + 1, newp); def_props: cur.def_props.clone(),
pi += 1; end_props: cur.end_props.clone(),
};
cur.end_props = props.clone();
done.push(std::mem::replace(&mut cur, newp));
ch = 0; ch = 0;
} }
first = false; first = false;
// The run index just after the last insertion: the next one lands there with no rescan.
let mut next: Option<usize> = None;
for (k, seg) in part.split('\u{b}').enumerate() { for (k, seg) in part.split('\u{b}').enumerate() {
let Some(p) = body.paragraphs.get_mut(pi) else { break };
if k > 0 { if k > 0 {
let idx = split_at(p, ch); let idx = next.unwrap_or_else(|| split_at(&mut cur, ch));
p.runs.insert(idx, Run { text: String::new(), props: props.clone(), kind: RunKind::Break }); cur.runs.insert(idx, Run { text: String::new(), props: props.clone(), kind: RunKind::Break });
ch += 1; ch += 1;
next = Some(idx + 1);
} }
if seg.is_empty() { if seg.is_empty() {
continue; continue;
} }
let idx = split_at(p, ch); let idx = next.unwrap_or_else(|| split_at(&mut cur, ch));
p.runs.insert(idx, Run::with(seg, props.clone())); cur.runs.insert(idx, Run::with(seg, props.clone()));
ch += seg.chars().count(); ch += seg.chars().count();
next = Some(idx + 1);
} }
if let Some(p) = body.paragraphs.get_mut(pi) { cur.normalize();
p.normalize(); if cur.runs.is_empty() {
if p.runs.is_empty() { cur.end_props = props.clone();
p.end_props = props.clone();
}
} }
} }
let pi = pi0 + done.len();
done.push(cur);
body.paragraphs.splice(pi0..=pi0, done);
(pi, ch) (pi, ch)
} }
@@ -252,8 +264,16 @@ pub fn slice(body: &TextBody, a: Pos, b: Pos) -> TextBody {
/// Insert a formatted body at `pos` (paste): first paragraph merges into the current one. /// Insert a formatted body at `pos` (paste): first paragraph merges into the current one.
pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos { pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
let mut caret = clamp(body, pos); let mut caret = clamp(body, pos);
// Plain text runs go straight in at the run index after the previous one and the paragraph is
// normalized once, not rescanned and merged for every source run.
let mut next: Option<usize> = None;
let mut dirty = false;
for (k, sp) in src.paragraphs.iter().enumerate() { for (k, sp) in src.paragraphs.iter().enumerate() {
if k > 0 { if k > 0 {
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
p.normalize();
}
(next, dirty) = (None, false);
caret = insert(body, caret, "\n", None); caret = insert(body, caret, "\n", None);
if let Some(p) = body.paragraphs.get_mut(caret.0) { if let Some(p) = body.paragraphs.get_mut(caret.0) {
p.level = sp.level; p.level = sp.level;
@@ -261,6 +281,22 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
} }
} }
for r in &sp.runs { for r in &sp.runs {
if r.kind == RunKind::Text
&& !r.text.is_empty()
&& !r.text.contains(['\n', '\r', '\u{b}'])
&& let Some(p) = body.paragraphs.get_mut(caret.0)
{
let idx = next.unwrap_or_else(|| split_at(p, caret.1));
p.runs.insert(idx, Run::with(&r.text, r.props.clone()));
caret.1 += r.text.chars().count();
(next, dirty) = (Some(idx + 1), true);
continue;
}
// Anything else takes the general path, after the pending runs are merged.
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
p.normalize();
}
(next, dirty) = (None, false);
match r.kind { match r.kind {
RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())), RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())),
RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())), RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())),
@@ -274,6 +310,9 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
} }
} }
} }
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
p.normalize();
}
caret caret
} }
@@ -568,6 +607,74 @@ mod tests {
assert_eq!(caret, (0, 5)); assert_eq!(caret, (0, 5));
} }
/// The paste as it ran before the fast path: one `insert` (and normalize) per source run.
fn insert_body_by_runs(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
let mut caret = clamp(body, pos);
for (k, sp) in src.paragraphs.iter().enumerate() {
if k > 0 {
caret = insert(body, caret, "\n", None);
if let Some(p) = body.paragraphs.get_mut(caret.0) {
p.level = sp.level;
p.props = sp.props.clone();
}
}
for r in &sp.runs {
match r.kind {
RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())),
RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())),
_ => {
if let Some(p) = body.paragraphs.get_mut(caret.0) {
let idx = split_at(p, caret.1);
p.runs.insert(idx, r.clone());
caret.1 += r.char_len();
}
}
}
}
}
caret
}
#[test]
fn paste_matches_inserting_run_by_run() {
let bold = |t: &str| Run::with(t, RunProps { bold: Some(true), ..Default::default() });
let mut src = body("");
src.paragraphs = vec![
Paragraph {
runs: vec![Run::with("ab", RunProps::default()), Run::with("cd", RunProps::default()), bold("ef"), bold("g")],
..Default::default()
},
Paragraph {
runs: vec![
bold("h"),
Run { text: String::new(), props: RunProps::default(), kind: RunKind::Break },
Run::with("i", RunProps::default()),
],
level: 1,
..Default::default()
},
Paragraph { runs: vec![Run::with("jk", RunProps::default())], ..Default::default() },
];
for at in [0, 1, 3, 6] {
let mut dest = body("XYZabc");
format(&mut dest, (0, 2), (0, 4), &|p| p.italic = Some(true));
let mut want = dest.clone();
let want_caret = insert_body_by_runs(&mut want, (0, at), &src);
let caret = insert_body(&mut dest, (0, at), &src);
assert_eq!(caret, want_caret, "caret at {at}");
assert_eq!(format!("{:?}", dest.paragraphs), format!("{:?}", want.paragraphs), "paste at {at}");
}
}
#[test]
fn multiline_insert_splits_into_paragraphs() {
let mut b = body("abcd");
let c = insert(&mut b, (0, 2), "x\ny\u{b}z\n\nw", None);
assert_eq!(c, (3, 1));
let texts: Vec<String> = b.paragraphs.iter().map(Paragraph::text).collect();
assert_eq!(texts, ["abx", "y\u{b}z", "", "wcd"]);
}
#[test] #[test]
fn words_and_moves() { fn words_and_moves() {
let b = body("Hello brave world"); let b = body("Hello brave world");
+13
View File
@@ -143,9 +143,14 @@ fn group(digits: &str, sep: char) -> String {
out out
} }
/// Longest number format applied, as in Excel (255 characters); a longer one formats as
/// General, so a chart cannot make every label expand a huge format.
pub const MAX_CODE_LEN: usize = 255;
/// `v` formatted with the Excel number format `code` in language `lang` (axis labels: the text /// `v` formatted with the Excel number format `code` in language `lang` (axis labels: the text
/// section of a format doesn't apply). /// section of a format doesn't apply).
pub fn format(code: &str, v: f64, lang: Option<&str>) -> String { pub fn format(code: &str, v: f64, lang: Option<&str>) -> String {
let code = if code.len() > MAX_CODE_LEN * 4 || code.chars().count() > MAX_CODE_LEN { "General" } else { code };
let (sep, dec) = marks(lang); let (sep, dec) = marks(lang);
if !v.is_finite() { if !v.is_finite() {
return String::new(); return String::new();
@@ -299,6 +304,14 @@ mod tests {
const RUB: &str = r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#; const RUB: &str = r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#;
#[test]
fn an_overlong_format_is_general() {
let code = "0".repeat(100_000);
assert_eq!(format(&code, 12.5, None), format("General", 12.5, None));
let ok = "0".repeat(MAX_CODE_LEN);
assert_eq!(format(&ok, 7.0, None).len(), MAX_CODE_LEN);
}
#[test] #[test]
fn scientific_subnormal() { fn scientific_subnormal() {
assert_eq!(format("0.00E+00", 5e-324, None), "4.94E-324"); assert_eq!(format("0.00E+00", 5e-324, None), "4.94E-324");
+6
View File
@@ -415,6 +415,11 @@ impl CaptureScanner {
} }
fn read_parts(src: &(impl ByteSource + ?Sized), parts: &[(u64, u32)]) -> Result<Vec<u8>> { fn read_parts(src: &(impl ByteSource + ?Sized), parts: &[(u64, u32)]) -> Result<Vec<u8>> {
// Ranges come from a scan of some source; check them against this one before sizing a buffer.
let len = src.len();
if parts.iter().any(|&(o, n)| o.checked_add(u64::from(n)).is_none_or(|end| end > len)) {
return Err(Error::Invalid("packet range lies outside the source".into()));
}
let mut v = Vec::with_capacity(parts.iter().map(|p| p.1 as usize).sum()); let mut v = Vec::with_capacity(parts.iter().map(|p| p.1 as usize).sum());
for &(o, n) in parts { for &(o, n) in parts {
let a = v.len(); let a = v.len();
@@ -508,6 +513,7 @@ impl OpusTiming {
c = timing_add(c, i64::from(durations[k]))?; c = timing_add(c, i64::from(durations[k]))?;
ends[k] = Some(c); ends[k] = Some(c);
} }
cursor = Some(c);
break; break;
} }
let Some(g) = stream.packets[j].granule else { break }; let Some(g) = stream.packets[j].granule else { break };
+31
View File
@@ -183,6 +183,27 @@ fn opus_toc_durations() {
assert_eq!(opus_packet_samples([0xF8, 0], 0), None); assert_eq!(opus_packet_samples([0xF8, 0], 0), None);
} }
#[test]
fn opus_timing_includes_packets_after_the_last_known_granule() {
let f = opus_file(6, 312, 0);
let o = open(&f).unwrap();
let mut stream = o.streams[0].clone();
// Missing granules on complete-packet pages are non-conforming. The existing recovery
// fallback still assigns their packet positions; total must include the same samples.
stream.packets.last_mut().unwrap().granule = None;
let timing = OpusTiming::of(&stream, 312).unwrap();
assert_eq!(timing.starts, vec![-312, 648, 1608, 2568, 3528, 4488]);
assert_eq!(timing.total, 6 * 960 - 312);
// With no usable granule, every complete packet still contributes its TOC duration.
for packet in &mut stream.packets {
packet.granule = None;
}
let timing = OpusTiming::of(&stream, 312).unwrap();
assert_eq!(timing.starts, vec![-312, 648, 1608, 2568, 3528, 4488]);
assert_eq!(timing.total, 6 * 960 - 312);
}
#[test] #[test]
fn opus_timing_rejects_a_granule_that_overflows() { fn opus_timing_rejects_a_granule_that_overflows() {
let packet = |granule: i64| Stream { let packet = |granule: i64| Stream {
@@ -274,3 +295,13 @@ fn a_budget_too_small_to_search_is_spent() {
let f = opus_file(6, 312, 0); let f = opus_file(6, 312, 0);
assert_eq!(scanner.find(&f, 0, f.len() as u64).unwrap(), None); assert_eq!(scanner.find(&f, 0, f.len() as u64).unwrap(), None);
} }
#[test]
fn read_packet_checks_its_ranges_against_the_source_before_allocating() {
let f = opus_file(10, 312, 500);
let o = open(&f).unwrap();
let s = o.stream_of(Codec::Opus).unwrap();
// The last packet lies past the end of a source cut short: an error, not a read of missing bytes.
let short = &f[..f.len() / 2];
assert!(matches!(o.read_packet(&short, s, 9), Err(Error::Invalid(_))));
}
+40 -28
View File
@@ -301,18 +301,14 @@ pub fn master_text_pieces(pres: &Presentation, m: &deckcraft_model::Master, pape
let (kx, ky) = (paper.0 / pres.notes_size.width.max(1.0), paper.1 / pres.notes_size.height.max(1.0)); let (kx, ky) = (paper.0 / pres.notes_size.width.max(1.0), paper.1 / pres.notes_size.height.max(1.0));
let mut out = vec![]; let mut out = vec![];
for pt in deckcraft_render::place_master_texts(pres, m, n) { for pt in deckcraft_render::place_master_texts(pres, m, n) {
let mut para_chars: HashMap<usize, Vec<char>> = HashMap::new();
for run in &pt.layout.runs { for run in &pt.layout.runs {
let Some(g) = run.glyphs.first() else { continue }; let Some(g) = run.glyphs.first() else { continue };
let text: String = pt let chars = para_chars.entry(run.para).or_insert_with(|| {
.body pt.body.paragraphs.get(run.para).map(|p| p.runs.iter().flat_map(|r| r.content().chars()).collect()).unwrap_or_default()
.paragraphs });
.get(run.para) let (a, b) = (run.chars.0.min(chars.len()), run.chars.1.min(chars.len()));
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>()) let text: String = chars.get(a..b.max(a)).unwrap_or_default().iter().collect();
.unwrap_or_default()
.chars()
.skip(run.chars.0)
.take(run.chars.1.saturating_sub(run.chars.0))
.collect();
if text.trim().is_empty() { if text.trim().is_empty() {
continue; continue;
} }
@@ -498,6 +494,9 @@ impl Backdrops {
/// Most pixels one slide raster may have; it is RGBA, and the backdrop and the content are two rasters. /// Most pixels one slide raster may have; it is RGBA, and the backdrop and the content are two rasters.
const MAX_RASTER_PIXELS: f64 = 64_000_000.0; const MAX_RASTER_PIXELS: f64 = 64_000_000.0;
/// Most ruled note lines one handout area gets (a letter page holds about 30).
const MAX_NOTE_LINES: usize = 500;
/// Render scale for a slide of `slide` units drawn `box_width` points wide at `dpi`, with the total pixel count capped. /// Render scale for a slide of `slide` units drawn `box_width` points wide at `dpi`, with the total pixel count capped.
fn raster_scale(box_width: f64, slide: (f64, f64), dpi: f64) -> f64 { fn raster_scale(box_width: f64, slide: (f64, f64), dpi: f64) -> f64 {
let scale = (box_width / 72.0 * dpi.clamp(36.0, 600.0)) / slide.0; let scale = (box_width / 72.0 * dpi.clamp(36.0, 600.0)) / slide.0;
@@ -522,7 +521,8 @@ impl Exporter<'_> {
let k = b.width() / sw; let k = b.width() / sw;
// Raster at the requested dpi relative to the box size on paper. // Raster at the requested dpi relative to the box size on paper.
let scale = raster_scale(b.width(), (sw, sh), self.opts.dpi); let scale = raster_scale(b.width(), (sw, sh), self.opts.dpi);
let ropts = RenderOpts { scale, threads: 4, ..Default::default() }; // One clock reading for the raster and the text layer, so a date field cannot differ between them.
let ropts = RenderOpts { scale, threads: 4, now: deckcraft_text::datetime::DateTime::now_local(), ..Default::default() };
// Two rasters per slide: the backdrop (background, master and layout graphics) is the same // Two rasters per slide: the backdrop (background, master and layout graphics) is the same
// on every slide that shares its layout, so the writer stores it once, and the slide's own // on every slide that shares its layout, so the writer stores it once, and the slide's own
// shapes draw over it (#845). // shapes draw over it (#845).
@@ -563,7 +563,7 @@ impl Exporter<'_> {
} }
if self.opts.text_layer { if self.opts.text_layer {
let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k); let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k);
for pt in deckcraft_render::place_slide(pres, i).texts { for pt in deckcraft_render::place_slide_with(pres, i, &ropts).texts {
self.text_layer(s, &pt, view, false)?; self.text_layer(s, &pt, view, false)?;
} }
let _ = sh; let _ = sh;
@@ -573,20 +573,17 @@ impl Exporter<'_> {
/// Invisible glyphs exactly over the rasterised text. /// Invisible glyphs exactly over the rasterised text.
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) -> Result<()> { fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) -> Result<()> {
// Each paragraph's characters, gathered once: a long paragraph wraps into many runs.
let mut para_chars: HashMap<usize, Vec<char>> = HashMap::new();
for run in &pt.layout.runs { for run in &pt.layout.runs {
if run.glyphs.is_empty() || run.size <= 0.0 { if run.glyphs.is_empty() || run.size <= 0.0 {
continue; continue;
} }
let text: String = pt let chars = para_chars.entry(run.para).or_insert_with(|| {
.body pt.body.paragraphs.get(run.para).map(|p| p.runs.iter().flat_map(|r| r.content().chars()).collect()).unwrap_or_default()
.paragraphs });
.get(run.para) let (a, b) = (run.chars.0.min(chars.len()), run.chars.1.min(chars.len()));
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>()) let text: String = chars.get(a..b.max(a)).unwrap_or_default().iter().collect();
.unwrap_or_default()
.chars()
.skip(run.chars.0)
.take(run.chars.1.saturating_sub(run.chars.0))
.collect();
if text.trim().is_empty() { if text.trim().is_empty() {
continue; continue;
} }
@@ -666,19 +663,28 @@ impl Exporter<'_> {
let lh = size * 1.3; let lh = size * 1.3;
let mut y = area.y0 + size; let mut y = area.y0 + size;
s.set_fill(Some(grey(0x20, 1.0))); s.set_fill(Some(grey(0x20, 1.0)));
let space = width(" ");
'outer: for para in text.split('\n') { 'outer: for para in text.split('\n') {
// The line's width is kept as words are added, not measured again for each word.
let mut line = String::new(); let mut line = String::new();
let mut line_w = 0.0;
for word in para.split(' ') { for word in para.split(' ') {
let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; let word_w = width(word);
if width(&cand) > area.width() && !line.is_empty() { let cand_w = if line.is_empty() { word_w } else { line_w + space + word_w };
if cand_w > area.width() && !line.is_empty() {
if y > area.y1 { if y > area.y1 {
break 'outer; break 'outer;
} }
s.draw_text(Point::from_xy(area.x0 as f32, y as f32), font.clone(), size as f32, &line, false, TextDirection::Auto); s.draw_text(Point::from_xy(area.x0 as f32, y as f32), font.clone(), size as f32, &line, false, TextDirection::Auto);
y += lh; y += lh;
line = word.to_string(); line = word.to_string();
line_w = word_w;
} else { } else {
line = cand; if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
line_w = cand_w;
} }
} }
if y > area.y1 { if y > area.y1 {
@@ -695,20 +701,26 @@ impl Exporter<'_> {
/// Ruled lines for handwritten notes (3-per-page handouts). /// Ruled lines for handwritten notes (3-per-page handouts).
fn note_lines(&self, s: &mut Surface, r: Rect) { fn note_lines(&self, s: &mut Surface, r: Rect) {
let mut y = r.y0 + 18.0; // Counted, not stepped: at huge coordinates adding 22 leaves y unchanged, and a huge page
// would want millions of rules.
let first = r.y0 + 18.0;
let count = ((r.y1 - first) / 22.0).floor() + 1.0;
if count.is_nan() || count < 1.0 {
return;
}
s.set_stroke(Some(krilla::paint::Stroke { s.set_stroke(Some(krilla::paint::Stroke {
paint: krilla::color::rgb::Color::new(0xB0, 0xB0, 0xB0).into(), paint: krilla::color::rgb::Color::new(0xB0, 0xB0, 0xB0).into(),
width: 0.5, width: 0.5,
..Default::default() ..Default::default()
})); }));
while y <= r.y1 { for i in 0..(count.min(MAX_NOTE_LINES as f64) as usize) {
let y = first + 22.0 * i as f64;
let mut p = kurbo::BezPath::new(); let mut p = kurbo::BezPath::new();
p.move_to((r.x0, y)); p.move_to((r.x0, y));
p.line_to((r.x1, y)); p.line_to((r.x1, y));
if let Some(path) = to_path(&p) { if let Some(path) = to_path(&p) {
s.draw_path(&path); s.draw_path(&path);
} }
y += 22.0;
} }
s.set_stroke(None); s.set_stroke(None);
} }
+70 -5
View File
@@ -269,8 +269,6 @@ fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() {
#[cfg_attr(not(target_arch = "wasm32"), test)] #[cfg_attr(not(target_arch = "wasm32"), test)]
fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() { fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
use deckcraft_model::{Shape, ShapeId, Xfrm}; use deckcraft_model::{Shape, ShapeId, Xfrm};
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Dict, Object, Stream};
let text = "Café Ελληνικά Привет"; let text = "Café Ελληνικά Привет";
let face = FontDb::global().face("Ubuntu", "Regular"); let face = FontDb::global().face("Ubuntu", "Regular");
@@ -292,8 +290,17 @@ fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
assert!(runs.iter().all(|run| run.face.family == "Ubuntu" && !run.glyphs.is_empty() && run.glyphs.iter().all(|glyph| glyph.0 != 0))); assert!(runs.iter().all(|run| run.face.family == "Ubuntu" && !run.glyphs.is_empty() && run.glyphs.iter().all(|glyph| glyph.0 != 0)));
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap(); let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); assert_eq!(page_text(&bytes, 0), text);
let page = &pdf.pages()[0]; }
/// The text of page `index`, read the way a PDF reader does: the content stream's text-showing
/// operators decoded through the page font's ToUnicode map.
fn page_text(bytes: &[u8], index: usize) -> String {
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Dict, Object, Stream};
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap();
let page = &pdf.pages()[index];
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap(); let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
let fonts = resources.get::<Dict<'_>>(b"Font").unwrap(); let fonts = resources.get::<Dict<'_>>(b"Font").unwrap();
assert_eq!(fonts.len(), 1); assert_eq!(fonts.len(), 1);
@@ -344,7 +351,51 @@ fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
_ => {} _ => {}
} }
} }
assert_eq!(extracted, text); extracted
}
fn ubuntu_runs(runs: Vec<deckcraft_model::text::Run>) -> TextBody {
let paragraph = deckcraft_model::text::Paragraph { runs, ..Default::default() };
let mut body = TextBody { paragraphs: vec![paragraph], ..Default::default() };
for run in &mut body.paragraphs[0].runs {
run.props.font = Some("Ubuntu".into());
run.props.size = Some(20.0);
}
body
}
fn deck_with_body(n: usize, body: TextBody) -> Presentation {
use deckcraft_model::{Shape, ShapeId, Xfrm};
let mut p = deck(n);
for slide in &mut p.slides {
Arc::make_mut(slide).shapes =
vec![Shape { id: ShapeId(9001), xfrm: Some(Xfrm::new(20.0, 20.0, 920.0, 100.0)), text: Some(body.clone()), ..Default::default() }];
}
p
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn slide_number_field_is_recovered_from_the_page_text_layer() {
use deckcraft_model::text::{Run, RunKind};
let mut field = Run::new("<#>");
field.kind = RunKind::Field { field: "slidenum".into() };
let p = deck_with_body(3, ubuntu_runs(vec![Run::new("Page "), field]));
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
for (i, want) in ["Page 1", "Page 2", "Page 3"].into_iter().enumerate() {
assert_eq!(page_text(&bytes, i), want);
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn line_break_keeps_the_text_layer_aligned_with_the_layout() {
use deckcraft_model::text::{Run, RunKind};
let mut brk = Run::new("");
brk.kind = RunKind::Break;
let p = deck_with_body(1, ubuntu_runs(vec![Run::new("one"), brk, Run::new("two")]));
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
assert_eq!(page_text(&bytes, 0), "onetwo");
} }
/// Notes pages and handouts print on the chosen paper, landscape included, with every slide box /// Notes pages and handouts print on the chosen paper, landscape included, with every slide box
@@ -677,3 +728,17 @@ fn raster_scale_caps_total_pixels_not_each_side() {
let s = raster_scale(720.0, (960.0, 540.0), 200.0); let s = raster_scale(720.0, (960.0, 540.0), 200.0);
assert!((s - 720.0 / 72.0 * 200.0 / 960.0).abs() < 1e-12); assert!((s - 720.0 / 72.0 * 200.0 / 960.0).abs() < 1e-12);
} }
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn huge_paper_and_long_notes_export_in_bounded_work() {
// At 1e19 pt adding a rule's 22 pt leaves y unchanged: the rules are counted, not stepped. A narrow page keeps the raster small.
let p = deck(1);
let bytes = export(&p, &PdfOptions { paper: (612.0, 1.0e19), ..opts(PageLayout::Handouts { per_page: 3 }) }).expect("export");
assert!(bytes.starts_with(b"%PDF-"));
// 20,000 short words of notes, wrapped with the line width kept as words are added.
let mut p = deck(1);
Arc::make_mut(&mut p.slides[0]).notes = TextBody::from_text(&"ab ".repeat(20_000));
let bytes = export(&p, &opts(PageLayout::Notes)).expect("export");
assert!(bytes.starts_with(b"%PDF-"));
}
+33 -3
View File
@@ -171,6 +171,7 @@ fn rels_of(doc: &xml::Doc, part: &str) -> Rels {
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("external")); let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("external"));
let target_raw = target.to_string(); let target_raw = target.to_string();
let target = if external { target_raw.clone() } else { resolve(&base, target) }; let target = if external { target_raw.clone() } else { resolve(&base, target) };
out.by_id.entry(id.to_string()).or_insert(out.items.len());
out.items.push(Rel { out.items.push(Rel {
id: id.to_string(), id: id.to_string(),
kind: ty.rsplit('/').next().unwrap_or(ty).to_string(), kind: ty.rsplit('/').next().unwrap_or(ty).to_string(),
@@ -211,11 +212,13 @@ pub struct Rel {
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct Rels { pub struct Rels {
pub items: Vec<Rel>, pub items: Vec<Rel>,
/// Id → index of its first relationship in `items`.
by_id: HashMap<String, usize>,
} }
impl Rels { impl Rels {
pub fn get(&self, id: &str) -> Option<&Rel> { pub fn get(&self, id: &str) -> Option<&Rel> {
self.items.iter().find(|r| r.id == id) self.by_id.get(id).and_then(|&i| self.items.get(i))
} }
pub fn target(&self, id: &str) -> Option<&str> { pub fn target(&self, id: &str) -> Option<&str> {
self.get(id).map(|r| r.target.as_str()) self.get(id).map(|r| r.target.as_str())
@@ -339,23 +342,29 @@ pub fn relative(from: &str, to: &str) -> String {
#[derive(Default)] #[derive(Default)]
pub struct RelsOut { pub struct RelsOut {
pub items: Vec<(String, String, String, bool)>, pub items: Vec<(String, String, String, bool)>,
/// (type, target, external) → id of the relationship [`RelsOut::add`] made for it.
added: HashMap<(String, String, bool), String>,
} }
impl RelsOut { impl RelsOut {
/// Add (or reuse) a relationship; `kind` is a full type URI or a suffix of the standard base. /// Add (or reuse) a relationship; `kind` is a full type URI or a suffix of the standard base.
pub fn add(&mut self, kind: &str, target: &str, external: bool) -> String { pub fn add(&mut self, kind: &str, target: &str, external: bool) -> String {
let ty = if kind.starts_with("http") { kind.to_string() } else { format!("{RT_BASE}{kind}") }; let ty = if kind.starts_with("http") { kind.to_string() } else { format!("{RT_BASE}{kind}") };
if let Some((id, ..)) = self.items.iter().find(|(_, t, tg, e)| *t == ty && tg == target && *e == external) { let key = (ty, target.to_string(), external);
if let Some(id) = self.added.get(&key) {
return id.clone(); return id.clone();
} }
let id = format!("rId{}", self.items.len() + 1); let id = format!("rId{}", self.items.len() + 1);
self.items.push((id.clone(), ty, target.to_string(), external)); self.items.push((id.clone(), key.0.clone(), key.1.clone(), external));
self.added.insert(key, id.clone());
id id
} }
/// Always add a new relationship even if an identical one exists. /// Always add a new relationship even if an identical one exists.
pub fn add_new(&mut self, kind: &str, target: &str, external: bool) -> String { pub fn add_new(&mut self, kind: &str, target: &str, external: bool) -> String {
let ty = if kind.starts_with("http") { kind.to_string() } else { format!("{RT_BASE}{kind}") }; let ty = if kind.starts_with("http") { kind.to_string() } else { format!("{RT_BASE}{kind}") };
let id = format!("rId{}", self.items.len() + 1); let id = format!("rId{}", self.items.len() + 1);
// `add` reuses the first relationship for a target, whichever call made it.
self.added.entry((ty.clone(), target.to_string(), external)).or_insert_with(|| id.clone());
self.items.push((id.clone(), ty, target.to_string(), external)); self.items.push((id.clone(), ty, target.to_string(), external));
id id
} }
@@ -500,6 +509,27 @@ mod tests {
assert!(p.get("e").is_some()); assert!(p.get("e").is_some());
} }
#[test]
fn many_relationships_are_found_by_index_and_first_id_wins() {
let n = 50_000;
let mut x = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
for i in 0..n {
x.push_str(&format!(r#"<Relationship Id="rId{i}" Type="{RT_BASE}image" Target="../media/image{i}.png"/>"#));
}
x.push_str(&format!(r#"<Relationship Id="rId0" Type="{RT_BASE}image" Target="../media/dup.png"/></Relationships>"#));
let rels = rels_from_bytes(x.as_bytes(), "ppt/slides/slide1.xml");
assert_eq!(rels.target("rId0"), Some("ppt/media/image0.png"), "the first of a repeated id wins");
assert!((0..n).all(|i| rels.target(&format!("rId{i}")) == Some(&*format!("ppt/media/image{i}.png"))));
let mut out = RelsOut::default();
let ids: Vec<String> = (0..n).map(|i| out.add("hyperlink", &format!("https://example.com/{i}"), true)).collect();
assert_eq!(out.add("hyperlink", "https://example.com/7", true), ids[7], "an identical target reuses its id");
assert_eq!(out.add("hyperlink", "https://example.com/7", false), format!("rId{}", n + 1), "internal is distinct");
let fresh = out.add_new("image", "../media/a.png", false);
assert_ne!(out.add_new("image", "../media/a.png", false), fresh);
assert_eq!(out.add("image", "../media/a.png", false), fresh, "add reuses the first add_new made");
}
#[test] #[test]
fn backslash_zip_entry_names_are_found() { fn backslash_zip_entry_names_are_found() {
let z = zip_of(&[("PPT\\Slides\\Slide1.xml", 3)]); let z = zip_of(&[("PPT\\Slides\\Slide1.xml", 3)]);
+25 -17
View File
@@ -46,6 +46,9 @@ pub struct Imp<'a> {
pub tag_bytes: usize, pub tag_bytes: usize,
/// The tag budget ran out and the warning was given. /// The tag budget ran out and the warning was given.
pub tags_capped: bool, pub tags_capped: bool,
/// Whether a part has relationships of its own, by normalized part name: looked up once
/// however many embedded objects point at the part.
pub has_rels: HashMap<String, bool>,
} }
impl Imp<'_> { impl Imp<'_> {
@@ -446,6 +449,7 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<
tags_by_part: HashMap::new(), tags_by_part: HashMap::new(),
tag_bytes: 0, tag_bytes: 0,
tags_capped: false, tags_capped: false,
has_rels: HashMap::new(),
}; };
let part = Part::new(&pkg, &theme_path, &doc); let part = Part::new(&pkg, &theme_path, &doc);
let theme = theme::read_theme(&mut imp, &part, &doc.root); let theme = theme::read_theme(&mut imp, &part, &doc.root);
@@ -487,6 +491,7 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
tags_by_part: HashMap::new(), tags_by_part: HashMap::new(),
tag_bytes: 0, tag_bytes: 0,
tags_capped: false, tags_capped: false,
has_rels: HashMap::new(),
}; };
// Slide ids first so hyperlinks anywhere can point at slides. // Slide ids first so hyperlinks anywhere can point at slides.
let slide_ids = register_slides(&mut imp, &docs, &slide_entries, &mut lost_slides); let slide_ids = register_slides(&mut imp, &docs, &slide_entries, &mut lost_slides);
@@ -663,52 +668,55 @@ fn read_slides(
let pkg = imp.pkg; let pkg = imp.pkg;
let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default(); let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
let mut file_slide_ids: HashMap<u32, SlideId> = HashMap::new(); let mut file_slide_ids: HashMap<u32, SlideId> = HashMap::new();
let mut notes_by_target: HashMap<String, TextBody> = HashMap::new(); // A notes or comments part belongs to one slide; one shared by many must not be read (and
// copied) once per slide that names it.
let mut owned_parts: HashSet<String> = HashSet::new();
for (file_id, path, id) in slide_ids { for (file_id, path, id) in slide_ids {
let Some(doc) = docs.get(&opc::norm(path)) else { continue }; let Some(doc) = docs.get(&opc::norm(path)) else { continue };
let part = Part::new(pkg, path, doc); let part = Part::new(pkg, path, doc);
let layout = part.rels.first_of("slideLayout").and_then(|r| layout_by_part.get(&opc::norm(&r.target)).copied()).unwrap_or(fallback_layout); let layout = part.rels.first_of("slideLayout").and_then(|r| layout_by_part.get(&opc::norm(&r.target)).copied()).unwrap_or(fallback_layout);
let mut slide = read_slide(imp, &part, doc, *id, layout); let mut slide = read_slide(imp, &part, doc, *id, layout);
if let Some(notes) = slide_notes(imp, &part, &mut notes_by_target) { if let Some(notes) = slide_notes(imp, &part, &mut owned_parts) {
slide.notes = notes; slide.notes = notes;
} }
read_slide_comments(pkg, pres_part, &part, &mut slide); read_slide_comments(pkg, pres_part, &part, &mut slide, &mut owned_parts);
file_slide_ids.insert(*file_id, *id); file_slide_ids.insert(*file_id, *id);
p.slides.push(Arc::new(slide)); p.slides.push(Arc::new(slide));
} }
file_slide_ids file_slide_ids
} }
/// The slide's notes text, read once per notes part. /// The slide's notes text. A notes part is the notes of the first slide that names it.
fn slide_notes(imp: &mut Imp, part: &Part, cache: &mut HashMap<String, TextBody>) -> Option<TextBody> { fn slide_notes(imp: &mut Imp, part: &Part, owned: &mut HashSet<String>) -> Option<TextBody> {
let n = part.rels.first_of("notesSlide")?; let n = part.rels.first_of("notesSlide")?;
let key = opc::norm(&n.target); if !owned.insert(opc::norm(&n.target)) {
if let Some(cached) = cache.get(&key) { log::warn!("pptx: notes part {} is named by more than one slide; kept on the first", n.target);
return Some(cached.clone()); return None;
} }
let pkg = imp.pkg; let pkg = imp.pkg;
let nd = load(pkg, &n.target)?; let nd = load(pkg, &n.target)?;
let np = Part::new(pkg, &n.target, &nd); let np = Part::new(pkg, &n.target, &nd);
let notes = read_notes(imp, &np, &nd.root); Some(read_notes(imp, &np, &nd.root))
cache.insert(key, notes.clone());
Some(notes)
} }
/// Comments. A modern part and the legacy part can describe the same threads (PowerPoint /// Comments. A modern part and the legacy part can describe the same threads (PowerPoint
/// writes both): the modern one wins, its flattened legacy copy is skipped. /// writes both): the modern one wins, its flattened legacy copy is skipped.
fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide) { /// Each comments part is read once, for the first slide that names it.
let mut parts: Vec<(Doc, bool)> = vec![]; fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide, owned: &mut HashSet<String>) {
let mut parts: Vec<(Doc, bool, bool)> = vec![];
for c in part.rels.all_of("comments") { for c in part.rels.all_of("comments") {
// An already-owned part is not re-read, but its kind still decides modern precedence.
let fresh = owned.insert(opc::norm(&c.target));
if let Some(cd) = pkg.xml(&c.target) { if let Some(cd) = pkg.xml(&c.target) {
// The content type names the modern part; a package that types it only through a // The content type names the modern part; a package that types it only through a
// Default entry instead of an Override is still modern when its root is p188. // Default entry instead of an Override is still modern when its root is p188.
let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188"; let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188";
parts.push((cd, modern)); parts.push((cd, modern, fresh));
} }
} }
let has_modern = parts.iter().any(|(_, m)| *m); let has_modern = parts.iter().any(|(_, m, _)| *m);
for (cd, modern) in parts { for (cd, modern, fresh) in parts {
if has_modern && !modern { if !fresh || (has_modern && !modern) {
continue; continue;
} }
slide.comments.extend(read_comments(pkg, pres_part, &cd.root, modern)); slide.comments.extend(read_comments(pkg, pres_part, &cd.root, modern));
+26 -5
View File
@@ -664,12 +664,22 @@ fn push_text_runs(para: &mut Paragraph, props: RunProps, text: &str) {
/// Appends an Office Math (a14:m) run: its plain text and its OMML. /// Appends an Office Math (a14:m) run: its plain text and its OMML.
fn push_math_run(para: &mut Paragraph, part: &Part, e: &El) { fn push_math_run(para: &mut Paragraph, part: &Part, e: &El) {
if para.runs.len() < MAX_PARAGRAPH_RUNS { if para.runs.len() < MAX_PARAGRAPH_RUNS {
let mut text = String::new(); // The text of the outermost `t` elements: a `t` nested in another is already part of
let mut ts = vec![]; // the outer one's text, and taking it again would copy it once per nesting level.
e.find_all("t", &mut ts); fn outer_t(e: &El, depth: usize, text: &mut String) {
for t in ts { if depth > crate::xml::MAX_DEPTH {
text.push_str(&t.text()); return;
}
for c in e.elements() {
if c.is("t") {
text.push_str(&c.text());
} else {
outer_t(c, depth + 1, text);
}
}
} }
let mut text = String::new();
outer_t(e, 0, &mut text);
let omml = e.elements().map(|c| part.keep_inner(c)).collect::<String>(); let omml = e.elements().map(|c| part.keep_inner(c)).collect::<String>();
para.runs.push(Run { text, props: RunProps::default(), kind: RunKind::Math { omml } }); para.runs.push(Run { text, props: RunProps::default(), kind: RunKind::Math { omml } });
} }
@@ -714,6 +724,17 @@ mod tests {
use super::*; use super::*;
use crate::xml::parse; use crate::xml::parse;
#[test]
fn nested_math_text_is_taken_once() {
// 200 `m:t` nested in each other around one word: the word, not 200 copies of it.
let x = format!(r#"<a14:m xmlns:a14="a" xmlns:m="m"><m:oMath>{}word{}</m:oMath></a14:m>"#, "<m:t>".repeat(200), "</m:t>".repeat(200));
let d = parse(x.as_bytes()).unwrap();
let part = Part { name: "ppt/slides/slide1.xml".into(), rels: Default::default(), ns: vec![], inner_ns: vec![] };
let mut para = Paragraph::default();
push_math_run(&mut para, &part, &d.root);
assert_eq!(para.runs[0].text, "word");
}
#[test] #[test]
fn colors_and_transforms() { fn colors_and_transforms() {
let d = parse( let d = parse(
+2 -1
View File
@@ -475,7 +475,8 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
.into_iter() .into_iter()
.map(|id| { .map(|id| {
let rel = part.rels.get(&id).filter(|r| !r.external)?; let rel = part.rels.get(&id).filter(|r| !r.external)?;
if !imp.pkg.rels(&rel.target).items.is_empty() { let pkg = imp.pkg;
if *imp.has_rels.entry(crate::opc::norm(&rel.target)).or_insert_with(|| !pkg.rels(&rel.target).items.is_empty()) {
return None; return None;
} }
let (kind, target) = (rel.ty.clone(), rel.target.clone()); let (kind, target) = (rel.ty.clone(), rel.target.clone());
+13 -6
View File
@@ -210,13 +210,20 @@ pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableSt
let mut w = W::new(); let mut w = W::new();
w.open("a:tblStyleLst", A::new().a("xmlns:a", NS_A).a("def", "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}")); w.open("a:tblStyleLst", A::new().a("xmlns:a", NS_A).a("def", "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"));
let names = deckcraft_model::table::table_styles(); let names = deckcraft_model::table::table_styles();
let mut done: Vec<&str> = vec![]; // Indexes instead of scans: written ids (as given and ASCII lower-case), and the file's
// definitions by lower-case id (the first wins).
let mut done: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut done_lower: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut def_of: std::collections::HashMap<String, &deckcraft_model::table::TableStyleDef> = std::collections::HashMap::new();
for d in defs {
def_of.entry(d.id.to_ascii_lowercase()).or_insert(d);
}
for s in used { for s in used {
if done.contains(&s.as_str()) { if !done.insert(s.as_str()) {
continue; continue;
} }
done.push(s); done_lower.insert(s.to_ascii_lowercase());
if let Some(d) = defs.iter().find(|d| d.id.eq_ignore_ascii_case(s)) if let Some(d) = def_of.get(&s.to_ascii_lowercase())
&& crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle")) && crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle"))
{ {
w.raw(&d.raw); w.raw(&d.raw);
@@ -231,11 +238,11 @@ pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableSt
} }
// Styles the file defined but no table uses still belong to the deck. // Styles the file defined but no table uses still belong to the deck.
for d in defs { for d in defs {
if done.iter().any(|s| s.eq_ignore_ascii_case(&d.id)) { if done_lower.contains(&d.id.to_ascii_lowercase()) {
continue; continue;
} }
if crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle")) { if crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle")) {
done.push(&d.id); done_lower.insert(d.id.to_ascii_lowercase());
w.raw(&d.raw); w.raw(&d.raw);
} }
} }
+32 -7
View File
@@ -53,6 +53,8 @@ pub struct Exp<'a> {
pub p: &'a Presentation, pub p: &'a Presentation,
pub pkg: PackageOut, pub pkg: PackageOut,
media_parts: HashMap<MediaId, String>, media_parts: HashMap<MediaId, String>,
/// Each media id's item in `p.media` (the first with the id, as `Presentation::media`).
media_at: HashMap<MediaId, usize>,
n_image: u32, n_image: u32,
n_media: u32, n_media: u32,
n_chart: u32, n_chart: u32,
@@ -63,6 +65,8 @@ pub struct Exp<'a> {
n_kept: u32, n_kept: u32,
pub slide_parts: HashMap<SlideId, String>, pub slide_parts: HashMap<SlideId, String>,
pub table_styles: Vec<String>, pub table_styles: Vec<String>,
/// The ids in `table_styles`, for checking membership without a scan.
pub table_style_set: std::collections::HashSet<String>,
placeholder: Option<String>, placeholder: Option<String>,
} }
@@ -119,12 +123,17 @@ fn ext_for(content_type: &str, name: &str, data: &[u8]) -> String {
if !e.is_empty() && e.len() <= 5 && e.chars().all(|c| c.is_ascii_alphanumeric()) { e } else { "bin".into() } if !e.is_empty() && e.len() <= 5 && e.chars().all(|c| c.is_ascii_alphanumeric()) { e } else { "bin".into() }
} }
impl Exp<'_> { impl<'a> Exp<'a> {
/// The media item `id`, found through the index rather than a scan of every item.
pub fn media(&self, id: MediaId) -> Option<&'a deckcraft_model::MediaItem> {
let p: &'a Presentation = self.p;
self.media_at.get(&id).and_then(|&i| p.media.get(i))
}
fn media_part(&mut self, id: MediaId) -> Option<String> { fn media_part(&mut self, id: MediaId) -> Option<String> {
if let Some(p) = self.media_parts.get(&id) { if let Some(p) = self.media_parts.get(&id) {
return Some(p.clone()); return Some(p.clone());
} }
let m = self.p.media(id)?; let m = self.media(id)?;
if m.data.is_empty() { if m.data.is_empty() {
return None; return None;
} }
@@ -147,7 +156,7 @@ impl Exp<'_> {
} }
/// A relationship from `o` to a media item (embedded part, or external link). /// A relationship from `o` to a media item (embedded part, or external link).
pub fn media_rel(&mut self, o: &mut Out, id: MediaId, kind: &str) -> Option<String> { pub fn media_rel(&mut self, o: &mut Out, id: MediaId, kind: &str) -> Option<String> {
let m = self.p.media(id)?; let m = self.media(id)?;
if m.data.is_empty() { if m.data.is_empty() {
let link = m.link.clone()?; let link = m.link.clone()?;
return Some(o.rels.add(kind, &link, true)); return Some(o.rels.add(kind, &link, true));
@@ -156,7 +165,7 @@ impl Exp<'_> {
Some(o.rels.add(kind, &opc::relative(&o.name, &part), false)) Some(o.rels.add(kind, &opc::relative(&o.name, &part), false))
} }
pub fn media_rel_new(&mut self, o: &mut Out, id: MediaId, kind: &str) -> Option<String> { pub fn media_rel_new(&mut self, o: &mut Out, id: MediaId, kind: &str) -> Option<String> {
let m = self.p.media(id)?; let m = self.media(id)?;
if m.data.is_empty() { if m.data.is_empty() {
return None; return None;
} }
@@ -489,7 +498,8 @@ fn valid_date(s: &str) -> bool {
/// A copy of the presentation with every slide pointing at a layout that exists. /// A copy of the presentation with every slide pointing at a layout that exists.
fn sanitized(p: &Presentation) -> std::borrow::Cow<'_, Presentation> { fn sanitized(p: &Presentation) -> std::borrow::Cow<'_, Presentation> {
let mut need = p.masters.is_empty() || p.masters.iter().any(|m| m.layouts.is_empty()); let mut need = p.masters.is_empty() || p.masters.iter().any(|m| m.layouts.is_empty());
need |= p.slides.iter().any(|s| p.layout(s.layout).is_none()); let layouts: std::collections::HashSet<_> = p.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
need |= p.slides.iter().any(|s| !layouts.contains(&s.layout));
if !need { if !need {
return std::borrow::Cow::Borrowed(p); return std::borrow::Cow::Borrowed(p);
} }
@@ -532,6 +542,13 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
p, p,
pkg: PackageOut::default(), pkg: PackageOut::default(),
media_parts: HashMap::new(), media_parts: HashMap::new(),
media_at: {
let mut m = HashMap::new();
for (i, item) in p.media.iter().enumerate() {
m.entry(item.id).or_insert(i);
}
m
},
n_image: 0, n_image: 0,
n_media: 0, n_media: 0,
n_chart: 0, n_chart: 0,
@@ -541,6 +558,7 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
n_kept: 0, n_kept: 0,
slide_parts: HashMap::new(), slide_parts: HashMap::new(),
table_styles: vec![], table_styles: vec![],
table_style_set: Default::default(),
placeholder: None, placeholder: None,
}; };
for (i, s) in p.slides.iter().enumerate() { for (i, s) in p.slides.iter().enumerate() {
@@ -959,9 +977,16 @@ fn section_slides(p: &Presentation) -> Vec<(String, Vec<usize>)> {
if out.is_empty() { if out.is_empty() {
return out; return out;
} }
// Each slide's first section, found once: a section may name a slide many times.
let mut first: HashMap<SlideId, usize> = HashMap::new();
for (k, sec) in p.sections.iter().enumerate() {
for id in &sec.slides {
first.entry(*id).or_insert(k);
}
}
let mut cur = 0usize; let mut cur = 0usize;
for (i, s) in p.slides.iter().enumerate() { for (i, s) in p.slides.iter().enumerate() {
if let Some(k) = p.sections.iter().position(|sec| sec.slides.contains(&s.id)) if let Some(&k) = first.get(&s.id)
&& k >= cur && k >= cur
{ {
cur = k; cur = k;
@@ -1027,7 +1052,7 @@ fn write_embedded_fonts(x: &mut Exp, o: &mut Out) -> Vec<WrittenFont> {
if let Some(rid) = seen_part.get(&id) { if let Some(rid) = seen_part.get(&id) {
return Some(rid.clone()); return Some(rid.clone());
} }
let m = x.p.media(id)?; let m = x.media(id)?;
if m.data.is_empty() || m.data.len() > 32 * 1024 * 1024 { if m.data.is_empty() || m.data.len() > 32 * 1024 * 1024 {
return None; return None;
} }
+15 -5
View File
@@ -100,7 +100,7 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFill) { pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFill) {
// An image that lives outside the package is a linked picture: `a:blip` names it through // An image that lives outside the package is a linked picture: `a:blip` names it through
// `r:link`; `r:embed` may only reference a part inside the package. // `r:link`; `r:embed` may only reference a part inside the package.
let linked = x.p.media(pf.media).is_some_and(|m| m.data.is_empty() && m.link.is_some()); let linked = x.media(pf.media).is_some_and(|m| m.data.is_empty() && m.link.is_some());
let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o)); let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o));
w.open(tag, A::new().a("rotWithShape", if pf.upright { "0" } else { "1" })); w.open(tag, A::new().a("rotWithShape", if pf.upright { "0" } else { "1" }));
let adj = &pf.adjust; let adj = &pf.adjust;
@@ -449,7 +449,7 @@ pub fn hyperlink(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, h: &Hyperlink)
return; return;
}; };
w.open(tag, attrs); w.open(tag, attrs);
let name = x.p.media(sound_id).map(|m| sound_label(&m.name)).unwrap_or_else(|| "sound.wav".into()); let name = x.media(sound_id).map(|m| sound_label(&m.name)).unwrap_or_else(|| "sound.wav".into());
w.empty("a:snd", A::new().a("r:embed", sound_rid).a("name", name)); w.empty("a:snd", A::new().a("r:embed", sound_rid).a("name", name));
w.close(tag); w.close(tag);
} }
@@ -601,7 +601,7 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
Some(Bullet::Picture { media }) => match x.media_rel(o, *media, "image") { Some(Bullet::Picture { media }) => match x.media_rel(o, *media, "image") {
Some(rid) => { Some(rid) => {
w.open0("a:buBlip"); w.open0("a:buBlip");
let a = if x.p.media(*media).is_some_and(|m| m.data.is_empty() && m.link.is_some()) { let a = if x.media(*media).is_some_and(|m| m.data.is_empty() && m.link.is_some()) {
A::new().a("r:link", &rid) A::new().a("r:link", &rid)
} else { } else {
A::new().a("r:embed", &rid) A::new().a("r:embed", &rid)
@@ -762,17 +762,27 @@ pub fn text_body(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, tb: &TextBody,
} }
fn run_text(w: &mut W, x: &mut Exp, o: &mut Out, text: &str, props: &RunProps) { fn run_text(w: &mut W, x: &mut Exp, o: &mut Out, text: &str, props: &RunProps) {
// The run's properties are the same for every piece and line break: serialize them once.
let mut rpr_xml: Option<String> = None;
let mut put_rpr = |w: &mut W| match &rpr_xml {
Some(xml) => w.s.push_str(xml),
None => {
let start = w.s.len();
rpr(w, x, o, "a:rPr", props);
rpr_xml = Some(w.s[start..].to_string());
}
};
for (i, piece) in text.split(['\n', '\u{b}']).enumerate() { for (i, piece) in text.split(['\n', '\u{b}']).enumerate() {
if i > 0 { if i > 0 {
w.open0("a:br"); w.open0("a:br");
rpr(w, x, o, "a:rPr", props); put_rpr(w);
w.close("a:br"); w.close("a:br");
} }
if piece.is_empty() && i > 0 { if piece.is_empty() && i > 0 {
continue; continue;
} }
w.open0("a:r"); w.open0("a:r");
rpr(w, x, o, "a:rPr", props); put_rpr(w);
w.elt("a:t", piece); w.elt("a:t", piece);
w.close("a:r"); w.close("a:r");
} }
+4 -4
View File
@@ -446,7 +446,7 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth
} }
fn model_rel(x: &mut Exp, o: &mut Out, media: deckcraft_model::MediaId) -> Option<String> { fn model_rel(x: &mut Exp, o: &mut Out, media: deckcraft_model::MediaId) -> Option<String> {
if x.p.media(media).is_none_or(|m| m.data.is_empty()) { if x.media(media).is_none_or(|m| m.data.is_empty()) {
return None; return None;
} }
x.media_rel(o, media, crate::opc::RT_MODEL3D) x.media_rel(o, media, crate::opc::RT_MODEL3D)
@@ -479,7 +479,7 @@ fn write_model(
return; return;
}; };
let id = cx.id_for(s); let id = cx.id_for(s);
let poster = poster.filter(|p| x.p.media(*p).is_some_and(|m| !m.data.is_empty())); let poster = poster.filter(|p| x.media(*p).is_some_and(|m| !m.data.is_empty()));
let Some(poster) = poster else { let Some(poster) = poster else {
write_model_part(w, x, o, id, s, &rid); write_model_part(w, x, o, id, s, &rid);
return; return;
@@ -700,7 +700,7 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
t.normalize(); t.normalize();
w.open0("a:tbl"); w.open0("a:tbl");
let (guid, _) = crate::tables::guid_for_style(&t.style); let (guid, _) = crate::tables::guid_for_style(&t.style);
if !x.table_styles.contains(&t.style) { if x.table_style_set.insert(t.style.clone()) {
x.table_styles.push(t.style.clone()); x.table_styles.push(t.style.clone());
} }
w.open( w.open(
@@ -884,7 +884,7 @@ fn opaque(
p.name = label.to_string(); p.name = label.to_string();
} }
match preview { match preview {
Some(m) if x.p.media(m).is_some() => { Some(m) if x.media(m).is_some() => {
p.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media: m, ..Default::default() } }; p.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media: m, ..Default::default() } };
p.ph = None; p.ph = None;
shape_with_id(w, x, o, cx, &p, id); shape_with_id(w, x, o, cx, &p, id);
+42 -4
View File
@@ -1,7 +1,7 @@
//! Transitions and the animation timing tree. //! Transitions and the animation timing tree.
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles}; use deckcraft_model::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles};
use deckcraft_model::{Shape, ShapeId, ShapeKind, find_shape}; use deckcraft_model::{Shape, ShapeId, ShapeKind};
use super::shapes::IdMap; use super::shapes::IdMap;
use super::{Exp, Out}; use super::{Exp, Out};
@@ -16,7 +16,7 @@ pub fn transition(w: &mut W, x: &mut Exp, o: &mut Out, t: &Transition) {
601..=900 => "med", 601..=900 => "med",
_ => "slow", _ => "slow",
}; };
let sound = t.sound.and_then(|m| x.media_rel(o, m, "audio").map(|r| (r, x.p.media(m).map(|i| i.name.clone()).unwrap_or_default()))); let sound = t.sound.and_then(|m| x.media_rel(o, m, "audio").map(|r| (r, x.media(m).map(|i| i.name.clone()).unwrap_or_default())));
let snd = |w: &mut W| { let snd = |w: &mut W| {
if let Some((r, name)) = &sound { if let Some((r, name)) = &sound {
w.open0("p:sndAc"); w.open0("p:sndAc");
@@ -577,6 +577,21 @@ fn click_pars(w: &mut W, x: &mut Exp, ids: &mut Ids, clicks: &Groups, interactiv
} }
} }
/// Most effects one slide's timing writes; animations past it are left out.
const MAX_EFFECTS: usize = 100_000;
/// The shapes of the tree by id, the first in document order for a repeated id (as `find_shape`
/// finds it), so each animation resolves its shape without a tree walk.
fn shape_index<'a>(shapes: &'a [Shape], depth: usize, out: &mut std::collections::HashMap<ShapeId, &'a Shape>) {
if depth > 64 {
return;
}
for s in shapes {
out.entry(s.id).or_insert(s);
shape_index(s.children(), depth + 1, out);
}
}
/// Write `p:timing` for a slide's animations. /// Write `p:timing` for a slide's animations.
pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idmap: &IdMap) { pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idmap: &IdMap) {
let mut grp_count: std::collections::HashMap<u32, u32> = Default::default(); let mut grp_count: std::collections::HashMap<u32, u32> = Default::default();
@@ -585,16 +600,31 @@ pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idm
let mut bld: Vec<(u32, u32, bool)> = vec![]; let mut bld: Vec<(u32, u32, bool)> = vec![];
// The last paragraph effect written: (shape, class, trigger, grpId). // The last paragraph effect written: (shape, class, trigger, grpId).
let mut prev_para: Option<(ShapeId, AnimClass, Option<ShapeId>, u32)> = None; let mut prev_para: Option<(ShapeId, AnimClass, Option<ShapeId>, u32)> = None;
let mut by_id = std::collections::HashMap::new();
shape_index(shapes, 0, &mut by_id);
let mut roles_of: std::collections::HashMap<ShapeId, Vec<BuildRole>> = Default::default();
let mut effect_count = 0usize;
let mut warned = false;
for a in anims.iter().take(10_000) { for a in anims.iter().take(10_000) {
if effect_count >= MAX_EFFECTS {
if !warned {
log::warn!("pptx: more than {MAX_EFFECTS} animation effects; the rest are left out");
}
break;
}
let Some(spid) = idmap.get(a.shape) else { let Some(spid) = idmap.get(a.shape) else {
log::warn!("pptx: animation on missing shape {} skipped", a.shape); log::warn!("pptx: animation on missing shape {} skipped", a.shape);
continue; continue;
}; };
let Some(shape) = find_shape(shapes, a.shape) else { continue }; let Some(&shape) = by_id.get(&a.shape) else { continue };
let text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.is_some(); let text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.is_some();
let is_text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.as_ref().is_some_and(|t| !t.is_empty()); let is_text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.as_ref().is_some_and(|t| !t.is_empty());
let text = shape.text.as_ref().filter(|_| a.text_build == TextBuild::ByParagraph && is_text_sp); let text = shape.text.as_ref().filter(|_| a.text_build == TextBuild::ByParagraph && is_text_sp);
let roles = text.map(paragraph_build_roles).unwrap_or_default(); let no_roles = Vec::new();
let roles = match text {
Some(t) => roles_of.entry(a.shape).or_insert_with(|| paragraph_build_roles(t)),
None => &no_roles,
};
let by_para = roles.contains(&BuildRole::Top); let by_para = roles.contains(&BuildRole::Top);
// PowerPoint keeps a shape's consecutive paragraph effects in one build (`grpId`) marked // PowerPoint keeps a shape's consecutive paragraph effects in one build (`grpId`) marked
// `build="p"`; the reader turns them back into a by-paragraph build only if that plays them // `build="p"`; the reader turns them back into a by-paragraph build only if that plays them
@@ -632,11 +662,19 @@ pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idm
_ => continue, _ => continue,
}; };
let Ok(pi) = u32::try_from(i) else { break }; let Ok(pi) = u32::try_from(i) else { break };
if effect_count + effs.len() >= MAX_EFFECTS {
if !warned {
log::warn!("pptx: more than {MAX_EFFECTS} animation effects; the rest are left out");
warned = true;
}
break;
}
effs.push(Eff { a, spid, start, paragraph: Some(pi), grp }); effs.push(Eff { a, spid, start, paragraph: Some(pi), grp });
} }
} else { } else {
effs.push(Eff { a, spid, start: a.start, paragraph: a.paragraph, grp }); effs.push(Eff { a, spid, start: a.start, paragraph: a.paragraph, grp });
} }
effect_count += effs.len();
if text_sp && !joins { if text_sp && !joins {
bld.push((spid, grp, by_para || para_effect)); bld.push((spid, grp, by_para || para_effect));
} }
+27
View File
@@ -146,6 +146,33 @@ fn comments_on_several_slides_round_trip() {
assert_eq!(q.slides[2].comments[0].text, "Slide three"); assert_eq!(q.slides[2].comments[0].text, "Slide three");
} }
/// A slide that names a modern part another slide already owns, plus its own legacy part, must
/// not import the legacy copy: modern precedence holds even when the modern part is not re-read.
#[test]
fn owned_modern_part_still_suppresses_later_legacy() {
let mut p = deck();
let layout = p.slides[0].layout;
let s2 = defaults::new_slide(&mut p, layout).expect("slide");
p.slides.push(Arc::new(s2));
Arc::make_mut(&mut p.slides[1]).comments = vec![Comment { author: "Cara".into(), text: "Slide two".into(), ..Default::default() }];
let bytes = deckcraft_pptx::export(&p).expect("export");
let rels2 = part(&bytes, "ppt/slides/_rels/slide2.xml.rels").replace("modernComment2.xml", "modernComment1.xml");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip");
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start");
if f.name() == "ppt/slides/_rels/slide2.xml.rels" {
std::io::Write::write_all(&mut out, rels2.as_bytes()).expect("write");
} else {
std::io::copy(&mut f, &mut out).expect("copy");
}
}
let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import");
assert_eq!(q.slides[0].comments.len(), 2);
assert!(q.slides[1].comments.is_empty(), "{:?}", q.slides[1].comments);
}
/// A legacy-only package (what older tools read and write) still imports: 1/8 pt positions, /// A legacy-only package (what older tools read and write) still imports: 1/8 pt positions,
/// flattened replies. /// flattened replies.
#[test] #[test]
+31
View File
@@ -0,0 +1,31 @@
//! Export looks media, layouts, sections and table styles up by id: a deck with many of each and
//! many references to the last ones exports in time linear in its size.
use std::sync::Arc;
use deckcraft_model::style::PictureFill;
use deckcraft_model::{Section, Shape, ShapeId, ShapeKind, defaults};
#[test]
fn many_media_sections_and_table_styles_export() {
let mut p = defaults::new_presentation(None);
// 5,000 media items; 5,000 pictures of the last one.
let mut last = None;
for i in 0..5_000u32 {
last = p.add_media(&format!("m{i}.png"), "image/png", i.to_le_bytes().to_vec());
}
let last = last.unwrap();
let pic = |k: u32| Shape {
id: ShapeId(100_000 + k),
kind: ShapeKind::Picture { fill: PictureFill { media: last, ..Default::default() } },
..Default::default()
};
Arc::make_mut(&mut p.slides[0]).shapes = (0..5_000).map(pic).collect();
// A section naming the slide 50,000 times, after 2,000 empty sections.
let id = p.slides[0].id;
p.sections = (0..2_000).map(|i| Section { name: format!("s{i}"), slides: vec![] }).collect();
p.sections.push(Section { name: "last".into(), slides: vec![id; 50_000] });
let bytes = deckcraft_pptx::export(&p).expect("export");
let q = deckcraft_pptx::import(&bytes).expect("import");
assert_eq!(q.slides[0].shapes.iter().filter(|s| matches!(s.kind, ShapeKind::Picture { .. })).count(), 5_000);
}
+1
View File
@@ -9,6 +9,7 @@ mod corpus_kinds;
mod corpus_media; mod corpus_media;
mod corpus_style; mod corpus_style;
mod corpus_themes; mod corpus_themes;
mod export_scale;
mod fills; mod fills;
mod kept_parts; mod kept_parts;
mod locks; mod locks;
+31
View File
@@ -287,3 +287,34 @@ fn unreadable_slide_is_reported() {
assert_eq!(lost, vec!["ppt/slides/slide2.xml".to_string()]); assert_eq!(lost, vec!["ppt/slides/slide2.xml".to_string()]);
assert_eq!(deckcraft_pptx::import(&broken).expect("import").slides.len(), 1); assert_eq!(deckcraft_pptx::import(&broken).expect("import").slides.len(), 1);
} }
/// Many slides naming one notes part: it is read for the first slide only, not copied onto every
/// slide that names it.
#[test]
fn a_shared_notes_part_is_read_once() {
use std::sync::Arc;
let mut p = defaults::new_presentation(None);
let layout = p.slides[0].layout;
for _ in 0..3 {
let s = defaults::new_slide(&mut p, layout).expect("slide");
p.slides.push(Arc::new(s));
}
for (i, s) in p.slides.iter_mut().enumerate() {
Arc::make_mut(s).notes = deckcraft_model::text::TextBody::from_text(&format!("notes {i}"));
}
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
for (n, d) in es.iter_mut() {
if n.starts_with("ppt/slides/_rels/") {
let x = String::from_utf8(d.clone()).expect("utf8");
let x = x
.replace("notesSlide2.xml", "notesSlide1.xml")
.replace("notesSlide3.xml", "notesSlide1.xml")
.replace("notesSlide4.xml", "notesSlide1.xml");
*d = x.into_bytes();
}
}
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
assert_eq!(q.slides.len(), 4);
assert_eq!(q.slides[0].notes.text(), "notes 0");
assert!(q.slides[1..].iter().all(|s| s.notes.text().is_empty()), "a shared notes part is not copied");
}
+25 -8
View File
@@ -1,5 +1,7 @@
//! Charts: title, plot area with axes and gridlines, series marks, legend. //! Charts: title, plot area with axes and gridlines, series marks, legend.
use std::collections::HashMap;
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_model::Shape; use deckcraft_model::Shape;
use deckcraft_model::chart::{Chart, ChartType, TrendlineType, axis_scale}; use deckcraft_model::chart::{Chart, ChartType, TrendlineType, axis_scale};
@@ -85,9 +87,9 @@ fn bar_outline(ctx: &Ctx, s: &deckcraft_model::chart::Series) -> Option<(Rgba, f
/// Paint of one bar: a `<c:dPt>` fill wins, and a single-series chart that varies colours by point uses one accent per point /// Paint of one bar: a `<c:dPt>` fill wins, and a single-series chart that varies colours by point uses one accent per point
/// (not waterfalls, which are never read from a `<c:barChart>`). Pattern fills draw as patterns and /// (not waterfalls, which are never read from a `<c:barChart>`). Pattern fills draw as patterns and
/// `a:noFill` leaves the bar empty, as in PowerPoint. /// `a:noFill` leaves the bar empty, as in PowerPoint.
fn bar_paint(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Paint { fn bar_paint(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series, fills: Option<&HashMap<usize, &Fill>>) -> Paint {
if let Some((_, f)) = s.point_fills.iter().find(|(k, _)| *k == pi) { if let Some(f) = fills.and_then(|m| m.get(&pi)) {
return paint_of(ctx, Some(f), series_fill(ctx, c, si, Some(s))); return paint_of(ctx, Some(*f), series_fill(ctx, c, si, Some(s)));
} }
if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall { if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall {
return Paint::Color(series_color(ctx, c, pi)); return Paint::Color(series_color(ctx, c, pi));
@@ -883,6 +885,8 @@ fn draw_lines(ctx: &mut RenderContext, draw: &mut LineDrawing<'_, '_>) {
let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) }; let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) };
let series_stats = s.error_bars.as_ref().map(|_| deckcraft_model::chart::SeriesStats::of(&s.values)); let series_stats = s.error_bars.as_ref().map(|_| deckcraft_model::chart::SeriesStats::of(&s.values));
let mut pts = vec![]; let mut pts = vec![];
// Whether the series has any x value, found once rather than per point.
let any_x = s.x_values.iter().flatten().next().is_some();
for i in 0..ncat { for i in 0..ncat {
let Some(v) = s.values.get(i).copied().flatten() else { continue }; let Some(v) = s.values.get(i).copied().flatten() else { continue };
let v = if stacked { let v = if stacked {
@@ -896,11 +900,11 @@ fn draw_lines(ctx: &mut RenderContext, draw: &mut LineDrawing<'_, '_>) {
}; };
let x = match kind { let x = match kind {
ChartType::Scatter | ChartType::Bubble => { ChartType::Scatter | ChartType::Bubble => {
match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) { match (s.x_values.get(i).copied().flatten().and_then(to_x), any_x) {
(Some(x), _) => x, (Some(x), _) => x,
// No x values at all: the points are numbered 1, 2, 3... // No x values at all: the points are numbered 1, 2, 3...
(None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)), (None, false) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)),
(None, Some(_)) => continue, (None, true) => continue,
} }
} }
_ => plot.x0 + band * (slot(i) + 0.5), _ => plot.x0 + band * (slot(i) + 0.5),
@@ -970,6 +974,19 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6
let group = bar + step * (n - 1.0); let group = bar + step * (n - 1.0);
// Error bars need each series' mean and spread: worked out once, not per point. // Error bars need each series' mean and spread: worked out once, not per point.
let stats: Vec<deckcraft_model::chart::SeriesStats> = bars.iter().map(|(_, s)| deckcraft_model::chart::SeriesStats::of(&s.values)).collect(); let stats: Vec<deckcraft_model::chart::SeriesStats> = bars.iter().map(|(_, s)| deckcraft_model::chart::SeriesStats::of(&s.values)).collect();
// Each series' per-point fills by point (the first for a point wins), not searched per bar.
let point_fills: Vec<HashMap<usize, &Fill>> = bars
.iter()
.map(|(_, s)| {
let mut m = HashMap::new();
for (k, f) in &s.point_fills {
m.entry(*k).or_insert(f);
}
m
})
.collect();
// A waterfall's running bars, worked out once for all its categories.
let waterfall_bars = if waterfall { c.waterfall_bars() } else { Vec::new() };
for i in 0..ncat { for i in 0..ncat {
let mut pos_acc = 0.0; let mut pos_acc = 0.0;
let mut neg_acc = 0.0; let mut neg_acc = 0.0;
@@ -983,7 +1000,7 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6
let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total; let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total;
let start = band * slot(i) + (band - group) / 2.0 + if stacked { 0.0 } else { step * bi as f64 }; let start = band * slot(i) + (band - group) / 2.0 + if stacked { 0.0 } else { step * bi as f64 };
let (a, b) = if waterfall { let (a, b) = if waterfall {
c.waterfall_bars().get(i).copied().unwrap_or_default() waterfall_bars.get(i).copied().unwrap_or_default()
} else if stacked { } else if stacked {
if v >= 0.0 { if v >= 0.0 {
let r = (pos_acc, pos_acc + v); let r = (pos_acc, pos_acc + v);
@@ -1015,7 +1032,7 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6
}; };
Paint::Color(series_color(rctx, c, slot)) Paint::Color(series_color(rctx, c, slot))
} else { } else {
bar_paint(rctx, c, *si, i, s) bar_paint(rctx, c, *si, i, s, point_fills.get(bi))
}; };
fill_with(ctx, &paint, r.abs(), m); fill_with(ctx, &paint, r.abs(), m);
if !waterfall && let Some((oc, ow)) = bar_outline(rctx, s).filter(|o| o.1 > 0.0) { if !waterfall && let Some((oc, ow)) = bar_outline(rctx, s).filter(|o| o.1 > 0.0) {
+44 -6
View File
@@ -37,7 +37,8 @@ struct Cache {
bad_order: VecDeque<usize>, bad_order: VecDeque<usize>,
bad_bytes: usize, bad_bytes: usize,
/// GIFs by bytes address and adjustments (`None`: not animated); the bytes stay alive with them. /// GIFs by bytes address and adjustments (`None`: not animated); the bytes stay alive with them.
anims: HashMap<(usize, u64), (Arc<Vec<u8>>, Option<Arc<Anim>>)>, /// Decoded animations by (bytes, adjustments), each with the clock of its last use.
anims: HashMap<(usize, u64), (Arc<Vec<u8>>, Option<Arc<Anim>>, u64)>,
} }
/// Longest side, total decoded size and frame count kept for one animated GIF; the largest canvas /// Longest side, total decoded size and frame count kept for one animated GIF; the largest canvas
@@ -432,16 +433,33 @@ fn anim(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust) -> Option<Arc<Anim>> {
return None; return None;
} }
let key = (Arc::as_ptr(bytes) as usize, adjust_key(adj)); let key = (Arc::as_ptr(bytes) as usize, adjust_key(adj));
if let Some(a) = with(|c| c.anims.get(&key).map(|e| e.1.clone())) { let hit = with(|c| {
c.clock += 1;
let clock = c.clock;
c.anims.get_mut(&key).map(|e| {
e.2 = clock;
e.1.clone()
})
});
if let Some(a) = hit {
return a; return a;
} }
let a = decode_anim(bytes, adj).map(Arc::new); let a = decode_anim(bytes, adj).map(Arc::new);
with(|c| { with(|c| {
let held: usize = c.anims.values().filter_map(|e| e.1.as_ref()).map(|a| a.bytes()).sum(); // Make room by dropping the least recently used animations, not every one of them, so a
if held + a.as_ref().map_or(0, |a| a.bytes()) > GIF_CACHE_BUDGET || c.anims.len() >= 64 { // slide whose animations overflow the cache does not decode all of them again each frame.
c.anims.clear(); let new = a.as_ref().map_or(0, |a| a.bytes());
loop {
let held: usize = c.anims.values().filter_map(|e| e.1.as_ref()).map(|a| a.bytes()).sum();
if held + new <= GIF_CACHE_BUDGET && c.anims.len() < 64 {
break;
}
let Some(oldest) = c.anims.iter().min_by_key(|(_, e)| e.2).map(|(k, _)| *k) else { break };
c.anims.remove(&oldest);
} }
c.anims.insert(key, (bytes.clone(), a.clone())); c.clock += 1;
let clock = c.clock;
c.anims.insert(key, (bytes.clone(), a.clone(), clock));
}); });
a a
} }
@@ -535,6 +553,26 @@ mod tests {
out.into_inner() out.into_inner()
} }
/// A two-frame GIF whose first pixel is `n`, so each one has its own bytes.
fn gif(n: u8) -> Arc<Vec<u8>> {
use image::codecs::gif::GifEncoder;
let frame = |c: u8| image::Frame::new(image::RgbaImage::from_pixel(2, 2, image::Rgba([n, c, 0, 255])));
let mut out = Vec::new();
GifEncoder::new(&mut out).encode_frames([frame(0), frame(255)]).expect("encode");
Arc::new(out)
}
#[test]
fn a_full_animation_cache_drops_the_oldest_entry_not_all_of_them() {
let adj = PictureAdjust::default();
let gifs: Vec<_> = (0..70).map(gif).collect();
for g in &gifs {
assert!(anim(g, &adj).is_some());
}
let (len, newest) = with(|c| (c.anims.len(), c.anims.contains_key(&(Arc::as_ptr(&gifs[69]) as usize, adjust_key(&adj)))));
assert!(newest && len >= 32, "{len} cached");
}
#[test] #[test]
fn rgba_conversion_is_refused_past_the_cap() { fn rgba_conversion_is_refused_past_the_cap() {
let png = grey_png(100, 50); let png = grey_png(100, 50);
+1 -1
View File
@@ -54,7 +54,7 @@ pub use images::{decode as decode_image, gif_frame};
pub use morph_path::PathMorph; pub use morph_path::PathMorph;
pub use morph_text::TextMorph; pub use morph_text::TextMorph;
pub use pattern::{is_fg as pattern_is_fg, presets as pattern_presets, tile as pattern_tile}; pub use pattern::{is_fg as pattern_is_fg, presets as pattern_presets, tile as pattern_tile};
pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, text_outline}; pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, place_slide_with, text_outline};
pub use table::cell_body as table_cell_body; pub use table::cell_body as table_cell_body;
pub use table::cell_rects as table_cell_rects; pub use table::cell_rects as table_cell_rects;
pub use table::row_heights as table_row_heights; pub use table::row_heights as table_row_heights;
+22 -10
View File
@@ -76,16 +76,12 @@ fn tokens(l: &TextLayout, chars: bool) -> (Vec<Token>, Vec<(usize, usize)>) {
/// For each token of `b`, the token of `a` with the same text, in reading order, each used once. /// For each token of `b`, the token of `a` with the same text, in reading order, each used once.
fn matches(a: &[Token], b: &[Token]) -> Vec<Option<usize>> { fn matches(a: &[Token], b: &[Token]) -> Vec<Option<usize>> {
let mut used = vec![false; a.len()]; // Unused tokens of `a` by text, in reading order, so each lookup is a pop rather than a scan.
b.iter() let mut free: std::collections::HashMap<&str, std::collections::VecDeque<usize>> = std::collections::HashMap::new();
.map(|tb| { for (i, ta) in a.iter().enumerate() {
let i = a.iter().enumerate().position(|(i, ta)| !used.get(i).copied().unwrap_or(true) && ta.key == tb.key)?; free.entry(ta.key.as_str()).or_default().push_back(i);
if let Some(u) = used.get_mut(i) { }
*u = true; b.iter().map(|tb| free.get_mut(tb.key.as_str())?.pop_front()).collect()
}
Some(i)
})
.collect()
} }
fn layout(ctx: &Ctx, s: &Shape, x: Xfrm, fields: &dyn Fields) -> Option<TextLayout> { fn layout(ctx: &Ctx, s: &Shape, x: Xfrm, fields: &dyn Fields) -> Option<TextLayout> {
@@ -228,3 +224,19 @@ pub(crate) fn draw(
pub(crate) fn token_keys(l: &TextLayout, chars: bool) -> Vec<String> { pub(crate) fn token_keys(l: &TextLayout, chars: bool) -> Vec<String> {
tokens(l, chars).0.into_iter().map(|t| t.key).collect() tokens(l, chars).0.into_iter().map(|t| t.key).collect()
} }
#[cfg(test)]
mod match_tests {
use super::*;
fn tok(k: &str) -> Token {
Token { key: k.into(), glyphs: Vec::new(), origin: Point::ZERO, run: 0 }
}
#[test]
fn each_source_token_is_used_once_in_reading_order() {
let a: Vec<Token> = ["a", "b", "a"].map(tok).into();
let b: Vec<Token> = ["a", "a", "a", "b", "c"].map(tok).into();
assert_eq!(matches(&a, &b), vec![Some(0), Some(2), None, Some(1), None]);
}
}
+29 -6
View File
@@ -2,9 +2,9 @@
//! that put real text over a raster (PDF text layer, links) and for text extraction. //! that put real text over a raster (PDF text layer, links) and for text extraction.
use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::text::{Hyperlink, TextBody}; use deckcraft_model::text::{Hyperlink, RunKind, TextBody};
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind}; use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
use deckcraft_text::{Opts, TextLayout}; use deckcraft_text::{Fields, Opts, TextLayout};
use kurbo::{Affine, Rect}; use kurbo::{Affine, Rect};
use crate::{SlideFields, shape_geometry, table}; use crate::{SlideFields, shape_geometry, table};
@@ -52,10 +52,16 @@ pub struct Placed {
/// Text and links of slide `index` (master and layout graphics first, as rendered). /// Text and links of slide `index` (master and layout graphics first, as rendered).
pub fn place_slide(pres: &Presentation, index: usize) -> Placed { pub fn place_slide(pres: &Presentation, index: usize) -> Placed {
place_slide_with(pres, index, &crate::RenderOpts::default())
}
/// [`place_slide`] with the render options the slide was drawn with, so date fields take the same
/// date and time as the raster (`opts.now`).
pub fn place_slide_with(pres: &Presentation, index: usize, opts: &crate::RenderOpts) -> Placed {
let mut out = Placed::default(); let mut out = Placed::default();
let Some(slide) = pres.slides.get(index) else { return out }; let Some(slide) = pres.slides.get(index) else { return out };
let Some(rctx) = Ctx::for_slide(pres, slide) else { return out }; let Some(rctx) = Ctx::for_slide(pres, slide) else { return out };
let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), &crate::RenderOpts::default()); let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts);
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout); let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
if show_master { if show_master {
let mctx = Ctx::for_master(pres, rctx.master); let mctx = Ctx::for_master(pres, rctx.master);
@@ -131,9 +137,11 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
} }
ShapeKind::Table(t) => { ShapeKind::Table(t) => {
// Cell text, laid out exactly as the table draws it. // Cell text, laid out exactly as the table draws it.
// The table's style is looked up once, not once per cell.
let def = table::resolve_style(rctx, t);
for (r, c, rect) in table::cell_rects(t, &table::row_heights(rctx, t, fields)) { for (r, c, rect) in table::cell_rects(t, &table::row_heights(rctx, t, fields)) {
let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else { continue }; let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else { continue };
let body = table::cell_body(rctx, t, r, c, cell); let body = table::cell_body_with_def(rctx, def, t, r, c, cell);
let tmp = Shape::table_cell(body.clone()); let tmp = Shape::table_cell(body.clone());
place_text(out, rctx, fields, s.id, &tmp, &body, rect, m); place_text(out, rctx, fields, s.id, &tmp, &body, rect, m);
} }
@@ -152,9 +160,24 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId, s: &Shape, body: &TextBody, tr: Rect, m: Affine) { fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId, s: &Shape, body: &TextBody, tr: Rect, m: Affine) {
let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false }); let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false });
let m = crate::text_transform(m, tr, layout.rotation); let m = crate::text_transform(m, tr, layout.rotation);
// Field runs carry their saved text (`‹#›`); the layout drew their values, so the body that
// goes with it (character offsets, text layer) must hold the same values.
let mut placed = body.clone();
for (pi, para) in body.paragraphs.iter().enumerate() {
for (ri, run) in para.runs.iter().enumerate() {
let RunKind::Field { field } = &run.kind else { continue };
let lang = resolve::run(rctx, s, para, &run.props).lang;
if let Some(v) = fields.field_in(field, lang.as_deref())
&& let Some(out_run) = placed.paragraphs.get_mut(pi).and_then(|p| p.runs.get_mut(ri))
{
out_run.text = v;
out_run.kind = RunKind::Text;
}
}
}
// Text hyperlinks: one box per line segment of a linked run. // Text hyperlinks: one box per line segment of a linked run.
for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) { for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) {
let Some(link) = run_link(body, run.para, run.chars.0) else { continue }; let Some(link) = run_link(&placed, run.para, run.chars.0) else { continue };
let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue }; let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue };
// The run's visual extent (right-to-left runs run from right to left). // The run's visual extent (right-to-left runs run from right to left).
let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0); let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
@@ -162,7 +185,7 @@ fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId
let r = Rect::new(x0, line.top, x1, line.bottom); let r = Rect::new(x0, line.top, x1, line.bottom);
out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link, shape: owner, text: true }); out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link, shape: owner, text: true });
} }
out.texts.push(PlacedText { layout, transform: m, body: body.clone() }); out.texts.push(PlacedText { layout, transform: m, body: placed });
} }
/// The hyperlink of the model run containing character `at` of paragraph `para`. /// The hyperlink of the model run containing character `at` of paragraph `para`.
+27 -1
View File
@@ -33,6 +33,10 @@ pub struct Renderer {
/// Visits spent on outer-shadow silhouette passes, kept apart so they cannot use up the budget /// Visits spent on outer-shadow silhouette passes, kept apart so they cannot use up the budget
/// of the normal drawing that follows. /// of the normal drawing that follows.
shadow_work: usize, shadow_work: usize,
/// `RenderOpts::skip` as a set, with the slice (address, length) it was built from: the
/// slice can be as long as the slide, and is tested for every shape drawn. Forgotten when a
/// frame starts, so another frame's slice at the same address is never mistaken for it.
skip: Option<(usize, usize, std::collections::HashSet<ShapeId>)>,
} }
impl Default for Renderer { impl Default for Renderer {
@@ -48,6 +52,7 @@ impl Default for Renderer {
in_shadow_pass: false, in_shadow_pass: false,
render_work: 0, render_work: 0,
shadow_work: 0, shadow_work: 0,
skip: None,
} }
} }
} }
@@ -163,6 +168,7 @@ impl Renderer {
pub(crate) fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image { pub(crate) fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
self.render_work = 0; self.render_work = 0;
self.shadow_work = 0; self.shadow_work = 0;
self.skip = None;
self.in_shadow_pass = false; self.in_shadow_pass = false;
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide)); let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) }; let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
@@ -178,6 +184,7 @@ impl Renderer {
pub(crate) fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image { pub(crate) fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image {
self.render_work = 0; self.render_work = 0;
self.shadow_work = 0; self.shadow_work = 0;
self.skip = None;
self.in_shadow_pass = false; self.in_shadow_pass = false;
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide)); let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) }; let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
@@ -212,6 +219,7 @@ impl Renderer {
let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 }; let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 };
self.render_work = 0; self.render_work = 0;
self.shadow_work = 0; self.shadow_work = 0;
self.skip = None;
self.in_shadow_pass = false; self.in_shadow_pass = false;
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0)); let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0));
let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) }; let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) };
@@ -260,6 +268,7 @@ impl Renderer {
pub(crate) fn layout(&mut self, pres: &Presentation, mi: usize, li: Option<usize>, opts: &RenderOpts) -> Image { pub(crate) fn layout(&mut self, pres: &Presentation, mi: usize, li: Option<usize>, opts: &RenderOpts) -> Image {
self.render_work = 0; self.render_work = 0;
self.shadow_work = 0; self.shadow_work = 0;
self.skip = None;
self.in_shadow_pass = false; self.in_shadow_pass = false;
let (w, h) = output_size(pres, opts); let (w, h) = output_size(pres, opts);
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
@@ -393,8 +402,25 @@ impl Renderer {
geo geo
} }
/// Whether `id` is in `skip`, looked up in a set built once per frame and slice.
fn skipped(&mut self, skip: &[ShapeId], id: ShapeId) -> bool {
if skip.is_empty() {
return false;
}
let key = (skip.as_ptr() as usize, skip.len());
match &self.skip {
Some((p, n, set)) if (*p, *n) == key => set.contains(&id),
_ => {
let set: std::collections::HashSet<ShapeId> = skip.iter().copied().collect();
let hit = set.contains(&id);
self.skip = Some((key.0, key.1, set));
hit
}
}
}
fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) { if depth > 32 || (s.hidden && !f.opts.show_hidden) || self.skipped(f.opts.skip, s.id) {
return; return;
} }
let work = if self.in_shadow_pass { &mut self.shadow_work } else { &mut self.render_work }; let work = if self.in_shadow_pass { &mut self.shadow_work } else { &mut self.render_work };
+2 -1
View File
@@ -191,7 +191,8 @@ pub(crate) fn limit_dash_count(path: &BezPath, pat: Vec<f64>) -> Vec<f64> {
return pat; return pat;
} }
let len: f64 = path.segments().map(|s| s.arclen(1e-2)).sum(); let len: f64 = path.segments().map(|s| s.arclen(1e-2)).sum();
let dashes = len / period; // Every dash of the pattern counts, not each repeat of it: a custom pattern can hold many.
let dashes = len / period * pat.len().div_ceil(2) as f64;
if !len.is_finite() { if !len.is_finite() {
return vec![]; return vec![];
} }
+22 -1
View File
@@ -1,7 +1,7 @@
use super::{ use super::{
Ctx, Fields, Fill, Image, Opts, PathMorph, Point, RENDERER, Rect, Region, RenderOpts, Renderer, Rgba, ShapeState, SlideFields, TextMorph, Ctx, Fields, Fill, Image, Opts, PathMorph, Point, RENDERER, Rect, Region, RenderOpts, Renderer, Rgba, ShapeState, SlideFields, TextMorph,
crop_dest, dpi_for_scale, gif_frame, image_dpi, images, morph_path, morph_text, needs_filters, parse_path, pasteboard_region, place_slide, crop_dest, dpi_for_scale, gif_frame, image_dpi, images, morph_path, morph_text, needs_filters, parse_path, pasteboard_region, place_slide,
render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform, place_slide_with, render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
}; };
use deckcraft_model::chart::{ChartType, Trendline, TrendlineType}; use deckcraft_model::chart::{ChartType, Trendline, TrendlineType};
use deckcraft_model::style::ColorRef; use deckcraft_model::style::ColorRef;
@@ -2971,6 +2971,27 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() {
assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2])); assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2]));
} }
#[test]
fn placed_text_holds_the_field_value_not_the_saved_placeholder() {
let mut p = deck_with(vec![field_shape(Some("slidenum"), "<#>")]);
let second = p.slides[0].clone();
p.slides.push(second);
let placed = place_slide(&p, 1);
assert_eq!(placed.texts.len(), 1);
assert_eq!(placed.texts[0].body.text(), "2");
// The layout was drawn from the same value, so the offsets agree with the body.
assert_eq!(placed.texts[0].layout.runs.iter().map(|r| r.chars.1).max(), Some(1));
}
#[test]
fn placed_text_takes_its_date_from_the_given_render_options() {
use deckcraft_text::datetime::DateTime;
let p = deck_with(vec![field_shape(Some("datetime1"), "1/1/2000")]);
let at = |now: DateTime| place_slide_with(&p, 0, &RenderOpts { now: Some(now), ..Default::default() });
assert_eq!(at(DateTime::new(2026, 10, 9, 20, 5, 7)).texts[0].body.text(), "10/9/2026");
assert_eq!(at(DateTime::new(2027, 3, 4, 0, 0, 0)).texts[0].body.text(), "3/4/2027");
}
/// A chart that has no text colour of its own takes PowerPoint's default, the theme's text colour /// A chart that has no text colour of its own takes PowerPoint's default, the theme's text colour
/// at 65 % plus 35 %: light on Nocturne's dark slides (it used to be a fixed grey, near-invisible /// at 65 % plus 35 %: light on Nocturne's dark slides (it used to be a fixed grey, near-invisible
/// there), 595959 grey on Harbor's light ones. /// there), 595959 grey on Harbor's light ones.
+33
View File
@@ -990,4 +990,37 @@ fn a_tiny_dash_on_a_huge_shape_draws_without_running_out_of_memory() {
p.line_to((4.0e6, 0.0)); p.line_to((4.0e6, 0.0));
let pat = limit_dash_count(&p, vec![1.0, 1.0]); let pat = limit_dash_count(&p, vec![1.0, 1.0]);
assert!((4.0e6 / (pat[0] + pat[1]) - MAX_DASHES).abs() < 1.0, "{pat:?}"); assert!((4.0e6 / (pat[0] + pat[1]) - MAX_DASHES).abs() < 1.0, "{pat:?}");
// A custom pattern of 1,000 dashes counts each of them: the limit is on dashes drawn.
let pat = limit_dash_count(&p, vec![1.0; 2000]);
let period: f64 = pat.iter().sum();
assert!((4.0e6 / period * 1000.0 - MAX_DASHES).abs() < 1.0, "{}", period);
}
/// A bar chart with 20,000 points, each with its own fill, and a sparse scatter of the same size:
/// the per-point fills and the scatter's "has any x" are looked up, not searched per point.
#[test]
fn many_point_fills_and_sparse_scatter_x_render() {
use deckcraft_model::ShapeKind;
use deckcraft_model::chart::{Chart, ChartType};
use deckcraft_model::style::Fill;
let n = 20_000;
let chart = |kind: ChartType| {
let mut c = Chart::sample(kind);
c.series.truncate(1);
c.categories = (0..n).map(|i| i.to_string()).collect();
c.series[0].values = (0..n).map(|i| Some(i as f64)).collect();
c
};
let mut bars = chart(ChartType::Column);
bars.series[0].point_fills = (0..n).rev().map(|i| (i, Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0))))).collect();
let mut scatter = chart(ChartType::Scatter);
scatter.series[0].x_values = (0..n).map(|i| (i + 1 == n).then_some(1.0)).collect();
let shape = |id: u32, c: Chart| Shape {
id: ShapeId(id),
xfrm: Some(Xfrm::new(10.0, 10.0, 400.0, 300.0)),
kind: ShapeKind::Chart(Box::new(c)),
..Default::default()
};
let img = render(vec![shape(2, bars), shape(3, scatter)]);
assert!(img.width > 0);
} }
+122 -8
View File
@@ -35,22 +35,30 @@ pub(crate) fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody {
t t
} }
/// Most path elements this thread's glyph cache retains. Its entries are shared with the font
/// cache, but they stay alive here after that cache evicts them, so they are bounded by elements.
const GLYPH_CACHE_MAX_ELEMENTS: usize = 1 << 22;
thread_local! { thread_local! {
static GLYPH_CACHE: std::cell::RefCell<HashMap<(u32, u32), Arc<BezPath>>> = std::cell::RefCell::new(HashMap::new()); /// The cached outlines, and the path elements they hold.
static GLYPH_CACHE: std::cell::RefCell<(HashMap<(u32, u32), Arc<BezPath>>, usize)> = std::cell::RefCell::new((HashMap::new(), 0));
} }
pub(crate) fn glyph(face: &deckcraft_fonts::FontFace, gid: u32) -> Arc<BezPath> { pub(crate) fn glyph(face: &deckcraft_fonts::FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id(), gid); let key = (face.id(), gid);
if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().get(&key).cloned()) { if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().0.get(&key).cloned()) {
return p; return p;
} }
let p = FontDb::global().outline(face, gid); let p = FontDb::global().outline(face, gid);
GLYPH_CACHE.with(|c| { GLYPH_CACHE.with(|c| {
let mut c = c.borrow_mut(); let mut c = c.borrow_mut();
if c.len() > 200_000 { let n = p.elements().len();
c.clear(); if c.0.len() > 200_000 || c.1.saturating_add(n) > GLYPH_CACHE_MAX_ELEMENTS {
c.0.clear();
c.1 = 0;
} }
c.insert(key, p.clone()); c.1 += n;
c.0.insert(key, p.clone());
}); });
p p
} }
@@ -105,6 +113,7 @@ pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr:
pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)]) { pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)]) {
let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default(); let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default();
let m = text_transform(m, tr, l.rotation); let m = text_transform(m, tr, l.rotation);
let lines_of = LineIndex::new(l);
let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| { let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| {
let ps = para(d.para); let ps = para(d.para);
if !ps.visible || ps.opacity <= 0.001 { if !ps.visible || ps.opacity <= 0.001 {
@@ -144,7 +153,7 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
draw_glyph(ctx, run, *gid, at, ps.opacity); draw_glyph(ctx, run, *gid, at, ps.opacity);
continue; continue;
}; };
let li = line_at(l, run.para, *y); let li = lines_of.line_at(run.para, *y);
if !grads.iter().any(|(i, _)| *i == li) { if !grads.iter().any(|(i, _)| *i == li) {
let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] }; let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] };
let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect(); let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect();
@@ -191,8 +200,78 @@ pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) {
} }
} }
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline). /// Finds the line of a paragraph holding a glyph on baseline `y` without scanning the paragraph's
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize { /// lines for every glyph. A paragraph whose lines are ordered top to bottom (every layout the text
/// crate produces) is searched by bisection; any other falls back to a scan with the same answer.
pub(crate) struct LineIndex<'a> {
l: &'a deckcraft_text::TextLayout,
/// Per paragraph: its lines' tops, bottoms and baselines all rise with the line index.
ordered: Vec<bool>,
}
impl<'a> LineIndex<'a> {
pub(crate) fn new(l: &'a deckcraft_text::TextLayout) -> Self {
let ordered = l
.para_lines
.iter()
.map(|&(first, count)| {
let end = first.saturating_add(count).min(l.lines.len());
l.lines.get(first..end).is_some_and(|ls| {
ls.iter().all(|a| a.top <= a.bottom)
&& ls.windows(2).all(|w| w[0].top <= w[1].top && w[0].bottom <= w[1].bottom && w[0].baseline <= w[1].baseline)
})
})
.collect();
LineIndex { l, ordered }
}
/// The nearest line baseline to `y` among `lines` (the first of equals), as an index into `lines`.
fn nearest(lines: &[deckcraft_text::LineInfo], y: f64) -> Option<usize> {
let i = lines.partition_point(|li| li.baseline < y);
let best = match (i.checked_sub(1), lines.get(i)) {
(Some(p), Some(n)) => {
if (lines[p].baseline - y).abs() <= (n.baseline - y).abs() {
p
} else {
i
}
}
(Some(p), None) => p,
(None, Some(_)) => i,
(None, None) => return None,
};
let b = lines[best].baseline;
Some(lines.partition_point(|li| li.baseline < b))
}
/// The paragraph's lines when they are ordered, with the index of the first.
fn ordered_lines(&self, para: usize) -> Option<(usize, &[deckcraft_text::LineInfo])> {
let &(first, count) = self.l.para_lines.get(para)?;
let end = first.saturating_add(count).min(self.l.lines.len());
(self.ordered[para] && first < end).then(|| (first, &self.l.lines[first..end]))
}
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
pub(crate) fn line_at(&self, para: usize, y: f64) -> usize {
if let Some((first, ls)) = self.ordered_lines(para) {
return Self::nearest(ls, y).map_or(0, |i| first + i);
}
line_at_scan(self.l, para, y)
}
/// The first line of paragraph `para` whose box holds `y`, else the nearest baseline's line.
pub(crate) fn line_of(&self, para: usize, y: f64) -> Option<usize> {
let Some((first, ls)) = self.ordered_lines(para) else { return crate::warp::line_of_scan(self.l, para, y) };
let i = ls.partition_point(|li| li.bottom + 0.01 < y);
if ls.get(i).is_some_and(|li| y >= li.top - 0.01) {
return Some(first + i);
}
Self::nearest(ls, y).map(|i| first + i)
}
}
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline), by scanning.
fn line_at_scan(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
if let Some(&(first, count)) = l.para_lines.get(para) { if let Some(&(first, count)) = l.para_lines.get(para) {
if count == 0 { if count == 0 {
return 0; return 0;
@@ -318,3 +397,38 @@ fn draw_glyph_with(
ctx.stroke_path(&path); ctx.stroke_path(&path);
} }
} }
#[cfg(test)]
mod line_index_tests {
use super::*;
use deckcraft_text::{LineInfo, TextLayout};
fn layout(ordered: bool) -> TextLayout {
let line = |para, top: f64| LineInfo { para, top, baseline: top + 8.0, bottom: top + 10.0, ..Default::default() };
let mut lines: Vec<LineInfo> = (0..40).map(|i| line(usize::from(i >= 25), f64::from(i) * 10.0)).collect();
if !ordered {
lines.swap(3, 7);
}
TextLayout { lines, para_lines: vec![(0, 25), (25, 15)], ..Default::default() }
}
#[test]
fn bisection_matches_the_scan() {
for ordered in [true, false] {
let l = layout(ordered);
let idx = LineIndex::new(l_ref(&l));
assert_eq!(idx.ordered, vec![ordered, true]);
for para in 0..2 {
for k in -20..450 {
let y = f64::from(k) * 0.9;
assert_eq!(idx.line_at(para, y), line_at_scan(&l, para, y), "line_at {para} {y}");
assert_eq!(idx.line_of(para, y), crate::warp::line_of_scan(&l, para, y), "line_of {para} {y}");
}
}
}
}
fn l_ref(l: &TextLayout) -> &TextLayout {
l
}
}
+6 -4
View File
@@ -9,13 +9,14 @@ use deckcraft_text::{GlyphRun, TextLayout};
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _}; use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _};
use vello_cpu::{RenderContext, peniko}; use vello_cpu::{RenderContext, peniko};
use crate::text_draw::LineIndex;
use crate::{ParaState, color, glyph, scale_of, text_transform}; use crate::{ParaState, color, glyph, scale_of, text_transform};
/// Most points one warped glyph may have (a hostile size or zoom can't explode the work). /// Most points one warped glyph may have (a hostile size or zoom can't explode the work).
const MAX_POINTS: usize = 40_000; const MAX_POINTS: usize = 40_000;
/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to. /// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to, by scanning.
fn line_of(l: &TextLayout, para: usize, y: f64) -> Option<usize> { pub(crate) fn line_of_scan(l: &TextLayout, para: usize, y: f64) -> Option<usize> {
if let Some(&(first, count)) = l.para_lines.get(para) { if let Some(&(first, count)) = l.para_lines.get(para) {
if count == 0 { if count == 0 {
return None; return None;
@@ -110,6 +111,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default(); let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default();
let m = text_transform(m, tr, l.rotation); let m = text_transform(m, tr, l.rotation);
let n = l.lines.len(); let n = l.lines.len();
let lines_of = LineIndex::new(l);
let mut ink: Vec<Option<Rect>> = vec![None; n]; let mut ink: Vec<Option<Rect>> = vec![None; n];
let mut off: Vec<Option<f64>> = vec![None; n]; let mut off: Vec<Option<f64>> = vec![None; n];
let mut items: Vec<Item> = Vec::new(); let mut items: Vec<Item> = Vec::new();
@@ -117,7 +119,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
let k = run.size / run.face.upem.max(1.0); let k = run.size / run.face.upem.max(1.0);
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
for (gid, x, y) in &run.glyphs { for (gid, x, y) in &run.glyphs {
let Some(li) = line_of(l, run.para, *y) else { continue }; let Some(li) = lines_of.line_of(run.para, *y) else { continue };
if let Some(o) = off.get_mut(li) { if let Some(o) = off.get_mut(li) {
*o = Some(o.map_or(ref_offset(run), |v| v.max(ref_offset(run)))); *o = Some(o.map_or(ref_offset(run), |v| v.max(ref_offset(run))));
} }
@@ -156,7 +158,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
return; return;
} }
let y = (d.rect.y0 + d.rect.y1) / 2.0; let y = (d.rect.y0 + d.rect.y1) / 2.0;
let Some(li) = line_of(l, d.para, y) else { return }; let Some(li) = lines_of.line_of(d.para, y) else { return };
ctx.set_transform(m * Affine::translate(ps.offset)); ctx.set_transform(m * Affine::translate(ps.offset));
ctx.set_paint(color(d.color, ps.opacity)); ctx.set_paint(color(d.color, ps.opacity));
for r in d.pieces() { for r in d.pieces() {
+29 -12
View File
@@ -260,12 +260,15 @@ pub(crate) struct Tab {
align: TabAlign, align: TabAlign,
} }
/// The paragraph's tab stops in position order (a stop at no finite position is left out), so
/// [`tab_advance`] finds the next one by binary search instead of a scan per tab.
pub(crate) fn tabs_of(props: &ParaProps) -> Vec<Tab> { pub(crate) fn tabs_of(props: &ParaProps) -> Vec<Tab> {
props let mut tabs: Vec<Tab> = props
.tabs .tabs
.as_ref() .as_ref()
.map(|t| { .map(|t| {
t.iter() t.iter()
.filter(|s| s.pos.is_finite())
.map(|s| Tab { .map(|s| Tab {
pos: s.pos, pos: s.pos,
align: match s.align.as_str() { align: match s.align.as_str() {
@@ -277,7 +280,9 @@ pub(crate) fn tabs_of(props: &ParaProps) -> Vec<Tab> {
}) })
.collect() .collect()
}) })
.unwrap_or_default() .unwrap_or_default();
tabs.sort_by(|a, b| a.pos.total_cmp(&b.pos));
tabs
} }
/// Width of the text after the tab at cell `i` up to the next tab or line break, and the width of /// Width of the text after the tab at cell `i` up to the next tab or line break, and the width of
@@ -301,18 +306,30 @@ pub(crate) fn tab_segment(cells: &[Cell], i: usize) -> (f64, f64) {
/// (`seg`: its width, and its width before the decimal point) as the stop aligns it but never /// (`seg`: its width, and its width before the decimal point) as the stop aligns it but never
/// moving back, as PowerPoint does; past the last stop, to the next default stop. /// moving back, as PowerPoint does; past the last stop, to the next default stop.
pub(crate) fn tab_advance(x: f64, stops: &[Tab], default_tab: f64, seg: (f64, f64)) -> f64 { pub(crate) fn tab_advance(x: f64, stops: &[Tab], default_tab: f64, seg: (f64, f64)) -> f64 {
for s in stops { // `stops` is in position order (`tabs_of`): the next stop is the first past x.
if s.pos > x + 0.5 { // A NaN `x` (a NaN spacing upstream) skips the lookup and takes the finite default-stop path.
let to = s.pos - x; if let Some(s) = stops.get(stops.partition_point(|s| s.pos <= x + 0.5)).filter(|_| !x.is_nan()) {
return match s.align { let to = s.pos - x;
TabAlign::Left => to, return match s.align {
TabAlign::Right => (to - seg.0).max(0.0), TabAlign::Left => to,
TabAlign::Center => (to - seg.0 / 2.0).max(0.0), TabAlign::Right => (to - seg.0).max(0.0),
TabAlign::Decimal => (to - seg.1).max(0.0), TabAlign::Center => (to - seg.0 / 2.0).max(0.0),
}; TabAlign::Decimal => (to - seg.1).max(0.0),
} };
} }
let d = if default_tab > 1.0 { default_tab } else { 72.0 }; let d = if default_tab > 1.0 { default_tab } else { 72.0 };
let next = ((x / d).floor() + 1.0) * d; let next = ((x / d).floor() + 1.0) * d;
(next - x).max(1.0) (next - x).max(1.0)
} }
#[cfg(test)]
mod tab_tests {
use super::*;
#[test]
fn a_nan_position_takes_the_finite_default_stop() {
let stops = [Tab { pos: 100.0, align: TabAlign::Left }];
assert!(tab_advance(f64::NAN, &stops, 72.0, (0.0, 0.0)).is_finite());
assert_eq!(tab_advance(10.0, &stops, 72.0, (0.0, 0.0)), 90.0);
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ pub struct GlyphRun {
pub link: bool, pub link: bool,
/// Opacity of the fill on top of `color`'s own alpha (0 for no fill). /// Opacity of the fill on top of `color`'s own alpha (0 for no fill).
pub alpha: f64, pub alpha: f64,
pub gradient: Option<deckcraft_model::style::Gradient>, pub gradient: Option<std::sync::Arc<deckcraft_model::style::Gradient>>,
/// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`). /// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`).
pub effects: Option<RunEffects>, pub effects: Option<RunEffects>,
/// East Asian vertical text (`eaVert`): each glyph stands upright, turned a quarter turn /// East Asian vertical text (`eaVert`): each glyph stands upright, turned a quarter turn
+2 -2
View File
@@ -46,7 +46,7 @@ pub(crate) struct Style {
pub(crate) highlight: Option<Rgba>, pub(crate) highlight: Option<Rgba>,
pub(crate) outline: Option<(Rgba, f64)>, pub(crate) outline: Option<(Rgba, f64)>,
pub(crate) link: bool, pub(crate) link: bool,
pub(crate) gradient: Option<deckcraft_model::style::Gradient>, pub(crate) gradient: Option<std::sync::Arc<deckcraft_model::style::Gradient>>,
pub(crate) effects: Option<RunEffects>, pub(crate) effects: Option<RunEffects>,
/// "Regular", "Bold"… for the slot and fallback faces. /// "Regular", "Bold"… for the slot and fallback faces.
pub(crate) style_name: &'static str, pub(crate) style_name: &'static str,
@@ -136,7 +136,7 @@ pub(crate) fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgb
for s in &mut g.stops { for s in &mut g.stops {
s.color = deckcraft_model::ColorRef::rgb(ctx.color(&s.color, None)); s.color = deckcraft_model::ColorRef::rgb(ctx.color(&s.color, None));
} }
(g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g)) (g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(std::sync::Arc::new(g)))
} }
Some(Fill::None) => (Rgba::TRANSPARENT, 0.0, None), Some(Fill::None) => (Rgba::TRANSPARENT, 0.0, None),
_ => (resolve::text_color(ctx, r), 1.0, None), _ => (resolve::text_color(ctx, r), 1.0, None),
+10
View File
@@ -1416,6 +1416,16 @@ fn tab_stops_align_the_text_after_them() {
assert!((l.lines[0].edges[4].0 - x0(&l) - 72.0).abs() < 0.01); assert!((l.lines[0].edges[4].0 - x0(&l) - 72.0).abs() < 0.01);
} }
#[test]
fn many_tabs_over_many_stops_lay_out_by_position() {
// 20,000 stops given out of order, and 2,000 tabs: each tab finds the next stop by position.
let mut stops: Vec<(f64, &str)> = (1..=20_000).rev().map(|k| (k as f64 * 0.05 + 30.0, "l")).collect();
stops.push((f64::NAN, "l"));
let text = "a\t".repeat(2_000);
let l = tabbed(&text, &stops, 1.0e6);
assert!((l.lines[0].edges[2].0 - l.inner.x0 - 30.05).abs() < 0.01, "{:?}", &l.lines[0].edges[..3]);
}
#[test] #[test]
fn a_tab_past_the_end_moves_to_the_next_line() { fn a_tab_past_the_end_moves_to_the_next_line() {
// PowerPoint: "abcdefghij\tk\tl\tm…" in a narrow box puts the tab that doesn't fit on the next // PowerPoint: "abcdefghij\tk\tl\tm…" in a narrow box puts the tab that doesn't fit on the next
+37 -3
View File
@@ -112,6 +112,21 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
} }
} }
/// Slide index -> the first section starting there, built once instead of searched per slide.
fn section_starts(doc: &deckcraft_model::Presentation) -> std::collections::HashMap<usize, usize> {
let mut index_of = std::collections::HashMap::new();
for (i, s) in doc.slides.iter().enumerate() {
index_of.entry(s.id).or_insert(i);
}
let mut starts = std::collections::HashMap::new();
for (si, s) in doc.sections.iter().enumerate() {
if let Some(&i) = s.slides.first().and_then(|id| index_of.get(id)) {
starts.entry(i).or_insert(si);
}
}
starts
}
/// Render the scrollable thumbnail list, scrolling the current slide into view when it changes. /// Render the scrollable thumbnail list, scrolling the current slide into view when it changes.
fn list(app: &mut SlideApp, ui: &mut Ui) { fn list(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
@@ -135,11 +150,10 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
let ppp = ui.ctx().pixels_per_point(); let ppp = ui.ctx().pixels_per_point();
let mut budget = 3u32; let mut budget = 3u32;
let mut rects = vec![]; let mut rects = vec![];
let section_starts: Vec<(usize, usize)> = let section_starts = section_starts(&doc);
doc.sections.iter().enumerate().filter_map(|(si, s)| s.slides.first().and_then(|id| doc.slide_index(*id)).map(|i| (i, si))).collect();
let mut need_more = false; let mut need_more = false;
for i in 0..doc.slides.len() { for i in 0..doc.slides.len() {
if let Some((_, si)) = section_starts.iter().find(|(start, _)| *start == i) if let Some(si) = section_starts.get(&i)
&& let Some(sec) = doc.sections.get(*si) && let Some(sec) = doc.sections.get(*si)
{ {
ui.add_space(4.0); ui.add_space(4.0);
@@ -434,6 +448,26 @@ mod tests {
h h
} }
#[test]
fn section_starts_map_each_first_slide_to_its_first_section() {
let mut s = Session::with_new();
for _ in 0..3 {
s.execute("slide.new", &json!({})).unwrap();
}
let mut doc = (*s.active().unwrap().doc).clone();
let ids: Vec<_> = doc.slides.iter().map(|sl| sl.id).collect();
doc.sections = vec![
deckcraft_model::Section { name: "a".into(), slides: vec![ids[0], ids[1]] },
deckcraft_model::Section { name: "dup".into(), slides: vec![ids[0]] },
deckcraft_model::Section { name: "c".into(), slides: vec![ids[2]] },
deckcraft_model::Section { name: "empty".into(), slides: vec![] },
];
let starts = section_starts(&doc);
assert_eq!(starts.get(&0), Some(&0), "the first section starting at a slide wins");
assert_eq!(starts.get(&2), Some(&2));
assert_eq!(starts.len(), 2);
}
/// Vertical scroll offset of the thumbnail pane, as recorded each frame by `show`. /// Vertical scroll offset of the thumbnail pane, as recorded each frame by `show`.
fn thumbs_scroll(h: &Harness<'_, SlideApp>) -> f32 { fn thumbs_scroll(h: &Harness<'_, SlideApp>) -> f32 {
h.state().ui.thumbs_scroll h.state().ui.thumbs_scroll
+7 -1
View File
@@ -18,7 +18,13 @@ fn walk(dir: &Path, c: &mut Counts) {
let p = e.path(); let p = e.path();
let name = e.file_name(); let name = e.file_name();
let name = name.to_string_lossy(); let name = name.to_string_lossy();
if p.is_dir() { // `file_type` does not follow links, so a directory link is not walked (and aliases are
// not counted twice).
let Ok(kind) = e.file_type() else { continue };
if kind.is_symlink() {
continue;
}
if kind.is_dir() {
// Nested fuzz workspaces and build output are not part of the crate. // Nested fuzz workspaces and build output are not part of the crate.
if name != "target" && name != "fuzz" && !name.starts_with('.') { if name != "target" && name != "fuzz" && !name.starts_with('.') {
walk(&p, c); walk(&p, c);