Compare commits

..
32 changed files with 90 additions and 484 deletions
-1
View File
@@ -45,4 +45,3 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
Generated
+28 -28
View File
@@ -984,7 +984,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"arboard",
"chrono",
@@ -1006,7 +1006,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -1015,7 +1015,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1024,14 +1024,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-cli"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -1041,14 +1041,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"chrono",
"deckcraft-anim",
@@ -1074,7 +1074,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -1086,7 +1086,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-model",
"serde",
@@ -1097,7 +1097,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"kurbo",
"serde",
@@ -1105,7 +1105,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1114,7 +1114,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1123,21 +1123,21 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-matroska"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-mcp"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1147,7 +1147,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1165,7 +1165,7 @@ dependencies = [
[[package]]
name = "deckcraft-metafile"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"image",
"kurbo",
@@ -1174,7 +1174,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1186,15 +1186,15 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
version = "0.15.1"
version = "0.15.0"
[[package]]
name = "deckcraft-opus"
version = "0.15.1"
version = "0.15.0"
[[package]]
name = "deckcraft-pdf"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
@@ -1212,7 +1212,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1228,7 +1228,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1247,7 +1247,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1261,7 +1261,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"craft-ui",
"craft-update",
@@ -1286,7 +1286,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1295,7 +1295,7 @@ dependencies = [
[[package]]
name = "deckcraft-web"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -6177,7 +6177,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
version = "0.15.1"
version = "0.15.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
-4
View File
@@ -2,8 +2,6 @@
**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-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)
@@ -152,7 +150,6 @@ app's JSON control channel (`deckcraft --control PORT`).
| 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 | 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 |
@@ -333,7 +330,6 @@ app's JSON control channel (`deckcraft --control PORT`).
| 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-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 |
-3
View File
@@ -251,9 +251,6 @@ impl Popup {
if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) {
data.next = previous.next.clone();
}
if data.slides.is_none() {
data.slides = previous.slides.clone();
}
}
*target.borrow_mut() = Some(data);
*received.borrow_mut() = js_sys::Date::now();
+17 -1
View File
@@ -777,6 +777,14 @@ fn decode_frame(job: &FrameJob, pool: &Pool, stats: &mut DecodeStats) -> Result<
Ok((rf, pic))
}
/// Bytes all parallel tile states may hold of whole-frame CDEF and restoration metadata; past it
/// the tiles decode one after another on the frame's own metadata.
const TILE_METADATA_BUDGET: usize = 256 << 20;
fn tile_metadata_fits(tiles: usize, per_tile: usize) -> bool {
tiles.checked_mul(per_tile).is_some_and(|n| n <= TILE_METADATA_BUDGET)
}
/// Decode the tiles of a frame (tile number, data), in parallel when several; returns the final
/// CDFs of tile context_update_tile_id.
fn decode_tiles(pool: &Pool, fs: &mut FrameState, cdfs: &Cdfs, tiles: &[(usize, Vec<u8>)]) -> Result<Option<Box<Cdfs>>> {
@@ -790,7 +798,7 @@ fn decode_tiles(pool: &Pool, fs: &mut FrameState, cdfs: &Cdfs, tiles: &[(usize,
};
// Intra block copy reads the current frame through the tile's own buffer: keep whole-frame
// buffers (tiles one after another) for it.
if tiles.len() > 1 && pool.is_parallel() && !fs.fh.allow_intrabc {
if tiles.len() > 1 && pool.is_parallel() && !fs.fh.allow_intrabc && tile_metadata_fits(tiles.len(), fs.tile_metadata_bytes()) {
let mut states: Vec<TileState> = tiles
.iter()
.map(|(num, _)| {
@@ -1075,6 +1083,14 @@ mod guard_tests {
#[cfg(test)]
mod budget_tests {
use super::*;
#[test]
fn tile_metadata_copies_are_budgeted() {
assert!(tile_metadata_fits(64, 1 << 20));
assert!(!tile_metadata_fits(4096, 1 << 20));
assert!(!tile_metadata_fits(usize::MAX, 2));
}
use deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET;
fn color(mono: bool) -> crate::header::ColorConfig {
+7
View File
@@ -253,6 +253,13 @@ impl FrameState {
self.lr = t.lr;
}
/// Bytes of CDEF and loop-restoration metadata each parallel tile state copies whole.
pub fn tile_metadata_bytes(&self) -> usize {
let lr = &self.lr;
// 1 (type) + 6 (Wiener) + 1 (SGR set) + 2 (SGR weights) bytes per unit.
self.cdef.len() + lr.lr_type.iter().map(|v| v.len() * 10).sum::<usize>()
}
/// Private buffers for the tile covering mode info rows [r0, r1) x columns [c0, c1); tiles
/// on the right / bottom frame edge also cover the allocation margins.
pub fn tile_state(&self, r0: usize, r1: usize, c0: usize, c1: usize) -> TileState {
+1 -35
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 (threads, slide, index) = {
let st = s.doc()?;
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), super::opt_usize_param(p, "index", cmd)?)
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), usize_param(p, "index"))
};
if threads.is_empty() {
return Err(bad(cmd, "no comments"));
@@ -862,40 +862,6 @@ mod tests {
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]
fn timestamps_are_iso_utc() {
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 Format", "Shape Styles"],
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 (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",
"{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",
has_selection,
fill
),
+1 -2
View File
@@ -74,8 +74,7 @@ pub(super) fn change(s: &mut Session, p: &Value) -> Result<Value> {
/// `{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),
/// rotateWithShape?}`. The renderer keeps only linear and radial gradients upright when
/// `rotateWithShape` is false; rectangular and path gradients turn with the shape.
/// rotateWithShape?}`.
pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
let stops: Vec<GradientStop> = v
.get("stops")?
-3
View File
@@ -4321,9 +4321,6 @@ fn shape_fill_documents_each_gradient_key_and_kind() {
let params = cmd::parse_params(find_command("shape.fill").unwrap().params).unwrap();
let about = params.iter().find(|p| p.name == "gradient").unwrap().about;
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"] {
assert!(about.contains(kind), "{kind}: {about}");
let g = cmd::shape::gradient_param(&json!({
+3 -7
View File
@@ -1260,8 +1260,9 @@ impl FontDb {
fn instance(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
let open = style.find('{')?;
let base = self.find(family, style[..open].trim())?;
let Some(font) = base.skrifa().filter(|f| !f.axes().is_empty()) else { return Some(base) };
let axis_tags: Vec<[u8; 4]> = font.axes().iter().map(|a| a.tag().to_be_bytes()).collect();
if base.skrifa().is_none_or(|f| f.axes().is_empty()) {
return Some(base);
}
let mut coords = base.coords.clone();
for kv in style[open + 1..].trim_end_matches('}').split(',') {
let (k, v) = kv.split_once(':')?;
@@ -1271,11 +1272,6 @@ impl FontDb {
return None;
}
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) {
Some(c) => c.1 = v,
None => coords.push((tag, v)),
-5
View File
@@ -247,11 +247,6 @@ 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) {
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);
}
+1 -8
View File
@@ -299,8 +299,7 @@ impl Outline<'_> {
/// A one-character control symbol (`\\`, `\{`, `\~`, ...).
fn symbol(&mut self, n: u8) {
let c = self.rtf.get(self.i..).and_then(|r| r.chars().next());
self.i = advance(self.rtf, self.i, 1);
self.i += 1;
if self.st.skip {
return;
}
@@ -309,7 +308,6 @@ impl Outline<'_> {
b'~' => self.line.push('\u{a0}'),
b'_' => self.line.push('\u{2011}'),
b'\n' | b'\r' => self.flush(),
_ if !n.is_ascii() => self.line.extend(c),
_ => {}
}
}
@@ -421,11 +419,6 @@ mod tests {
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.
#[test]
fn non_breaking_hyphen_is_kept() {
-7
View File
@@ -451,13 +451,6 @@ 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
/// another tool; NaN is also saved as null) opens with the default size rather than failing
/// as damaged.
-6
View File
@@ -415,11 +415,6 @@ impl CaptureScanner {
}
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());
for &(o, n) in parts {
let a = v.len();
@@ -513,7 +508,6 @@ impl OpusTiming {
c = timing_add(c, i64::from(durations[k]))?;
ends[k] = Some(c);
}
cursor = Some(c);
break;
}
let Some(g) = stream.packets[j].granule else { break };
-31
View File
@@ -183,27 +183,6 @@ fn opus_toc_durations() {
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]
fn opus_timing_rejects_a_granule_that_overflows() {
let packet = |granule: i64| Stream {
@@ -295,13 +274,3 @@ fn a_budget_too_small_to_search_is_spent() {
let f = opus_file(6, 312, 0);
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(_))));
}
+4 -5
View File
@@ -307,7 +307,7 @@ pub fn master_text_pieces(pres: &Presentation, m: &deckcraft_model::Master, pape
.body
.paragraphs
.get(run.para)
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())
.unwrap_or_default()
.chars()
.skip(run.chars.0)
@@ -522,8 +522,7 @@ impl Exporter<'_> {
let k = b.width() / sw;
// Raster at the requested dpi relative to the box size on paper.
let scale = raster_scale(b.width(), (sw, sh), self.opts.dpi);
// 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() };
let ropts = RenderOpts { scale, threads: 4, ..Default::default() };
// 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
// shapes draw over it (#845).
@@ -564,7 +563,7 @@ impl Exporter<'_> {
}
if self.opts.text_layer {
let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k);
for pt in deckcraft_render::place_slide_with(pres, i, &ropts).texts {
for pt in deckcraft_render::place_slide(pres, i).texts {
self.text_layer(s, &pt, view, false)?;
}
let _ = sh;
@@ -582,7 +581,7 @@ impl Exporter<'_> {
.body
.paragraphs
.get(run.para)
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())
.unwrap_or_default()
.chars()
.skip(run.chars.0)
+5 -56
View File
@@ -269,6 +269,8 @@ fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() {
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
use deckcraft_model::{Shape, ShapeId, Xfrm};
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Dict, Object, Stream};
let text = "Café Ελληνικά Привет";
let face = FontDb::global().face("Ubuntu", "Regular");
@@ -290,17 +292,8 @@ 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)));
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
assert_eq!(page_text(&bytes, 0), text);
}
/// 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 pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let page = &pdf.pages()[0];
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
let fonts = resources.get::<Dict<'_>>(b"Font").unwrap();
assert_eq!(fonts.len(), 1);
@@ -351,51 +344,7 @@ fn page_text(bytes: &[u8], index: usize) -> String {
_ => {}
}
}
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");
assert_eq!(extracted, text);
}
/// Notes pages and handouts print on the chosen paper, landscape included, with every slide box
-1
View File
@@ -1351,7 +1351,6 @@ fn read_show(sp: &El, shows: &HashMap<String, String>) -> ShowSettings {
without_narration: sp.bool("showNarration") == Some(false),
without_animation: sp.bool("showAnimation") == Some(false),
use_timings: sp.bool("useTimings").unwrap_or(true),
show_media_controls: sp.find("showMediaCtrls").and_then(|e| e.bool("val")).unwrap_or(true),
..Default::default()
};
if sp.child("browse").is_some() {
-8
View File
@@ -1206,14 +1206,6 @@ fn pres_props_xml(p: &Presentation) -> Vec<u8> {
w.open0("p:penClr");
w.val("a:srgbClr", s.pen_color.hex());
w.close("p:penClr");
if !s.show_media_controls {
// "Show media controls" is PowerPoint's p14 extension; absent means shown.
w.open0("p:extLst");
w.open("p:ext", A::new().a("uri", "{2FDB2607-1784-4EEB-B798-7EB5836EED8A}"));
w.empty("p14:showMediaCtrls", A::new().a("xmlns:p14", NS_P14).a("val", 0));
w.close("p:ext");
w.close("p:extLst");
}
w.close("p:showPr");
w.close("p:presentationPr");
w.finish()
-28
View File
@@ -2258,34 +2258,6 @@ fn default_durations_match_powerpoint() {
}
}
/// Show media controls is PowerPoint's `p14:showMediaCtrls` extension inside `p:showPr`: off is
/// written there, under its extension uri and with the p14 namespace declared on the element;
/// on (the default) writes nothing.
#[test]
fn hidden_media_controls_survive_save_and_reopen() {
let props = |p: &Presentation| {
let bytes = deckcraft_pptx::export(p).expect("export");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
let mut x = String::new();
std::io::Read::read_to_string(&mut zip.by_name("ppt/presProps.xml").expect("presProps"), &mut x).expect("read");
x
};
let mut p = Presentation::default();
p.show.show_media_controls = false;
let xml = props(&p);
let show = xml.split("<p:showPr").nth(1).and_then(|r| r.split("</p:showPr>").next()).expect("p:showPr");
assert!(
show.contains(
"<p:ext uri=\"{2FDB2607-1784-4EEB-B798-7EB5836EED8A}\"><p14:showMediaCtrls xmlns:p14=\"http://schemas.microsoft.com/office/powerpoint/2010/main\" val=\"0\"/></p:ext>"
),
"{xml}"
);
assert!(!round(&p).show.show_media_controls);
let default = Presentation::default();
assert!(!props(&default).contains("showMediaCtrls"));
assert!(round(&default).show.show_media_controls);
}
#[test]
fn reflection_angles_and_skews_out_of_range_are_written_inside_the_schema() {
let mut p = Presentation::default();
+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_text::TextMorph;
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, place_slide_with, text_outline};
pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, text_outline};
pub use table::cell_body as table_cell_body;
pub use table::cell_rects as table_cell_rects;
pub use table::row_heights as table_row_heights;
+5 -26
View File
@@ -2,9 +2,9 @@
//! that put real text over a raster (PDF text layer, links) and for text extraction.
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::text::{Hyperlink, RunKind, TextBody};
use deckcraft_model::text::{Hyperlink, TextBody};
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
use deckcraft_text::{Fields, Opts, TextLayout};
use deckcraft_text::{Opts, TextLayout};
use kurbo::{Affine, Rect};
use crate::{SlideFields, shape_geometry, table};
@@ -52,16 +52,10 @@ pub struct Placed {
/// Text and links of slide `index` (master and layout graphics first, as rendered).
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 Some(slide) = pres.slides.get(index) 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(), opts);
let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), &crate::RenderOpts::default());
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
if show_master {
let mctx = Ctx::for_master(pres, rctx.master);
@@ -160,24 +154,9 @@ 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) {
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);
// 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.
for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) {
let Some(link) = run_link(&placed, run.para, run.chars.0) else { continue };
let Some(link) = run_link(body, 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 };
// 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);
@@ -185,7 +164,7 @@ fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId
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.texts.push(PlacedText { layout, transform: m, body: placed });
out.texts.push(PlacedText { layout, transform: m, body: body.clone() });
}
/// The hyperlink of the model run containing character `at` of paragraph `para`.
+1 -22
View File
@@ -1,7 +1,7 @@
use super::{
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,
place_slide_with, render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
};
use deckcraft_model::chart::{ChartType, Trendline, TrendlineType};
use deckcraft_model::style::ColorRef;
@@ -2971,27 +2971,6 @@ 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]));
}
#[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
/// 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.
-4
View File
@@ -144,10 +144,6 @@ impl SlideApp {
let visible = show::viewport_visible(ctx);
let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return };
let Some(show) = self.show.as_mut() else { return };
if show.presenter_grid && self.services.presenter.as_ref().is_some_and(|host| !host.is_open()) {
// A web popup closed with its grid open must not hold auto-advance while this tab is hidden (#1866).
show.presenter_grid = false;
}
if visible {
// A visible logic-only pass (`run_logic`) carries the last full frame's input time.
// Anchoring that stale time to `now` would move the hidden clock backwards if the
+5 -102
View File
@@ -49,15 +49,10 @@ pub struct Snapshot {
pub paused: bool,
pub notes_px: f32,
pub blank: Option<String>,
/// All slides is open in the presenter window (#1866); the audience keeps the current slide.
pub grid: bool,
pub zoom_armed: bool,
pub current: Option<Preview>,
pub next: Option<Preview>,
/// The All slides thumbnails, in slide order, while `grid` is on. `None` in a sent delta
/// means "unchanged since the last snapshot the window received".
#[serde(default)]
pub slides: Option<Vec<Preview>>,
}
/// Images acknowledged by a successful transport send, never merely attempted images.
@@ -65,7 +60,6 @@ pub struct Snapshot {
pub struct DeltaCache {
current: Option<Preview>,
next: Option<Preview>,
slides: Option<Vec<Preview>>,
token: String,
}
impl DeltaCache {
@@ -81,12 +75,6 @@ impl DeltaCache {
let next_changed = fresh || self.next != snapshot.next;
let current = snapshot.current.clone();
let next = snapshot.next.clone();
let slides = snapshot.slides.clone();
if !fresh && self.slides == slides {
snapshot.slides = None;
} else if snapshot.slides.is_none() {
snapshot.slides = Some(Vec::new());
}
if !current_changed {
snapshot.current = None;
}
@@ -96,28 +84,14 @@ impl DeltaCache {
send(snapshot, current_changed, next_changed)?;
self.current = current;
self.next = next;
self.slides = slides;
self.token = token.to_owned();
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Command {
Key {
name: String,
command: bool,
shift: bool,
alt: bool,
ctrl: bool,
},
Zoom {
x: f32,
y: f32,
},
/// A slide chosen in the presenter window's All slides grid (#1866).
Goto {
slide: usize,
},
Key { name: String, command: bool, shift: bool, alt: bool, ctrl: bool },
Zoom { x: f32, y: f32 },
End,
}
/// Implemented by the browser host. Native hosts retain the existing egui viewport.
@@ -138,8 +112,6 @@ pub struct View {
next: Option<egui::TextureHandle>,
previous_current: Option<Preview>,
previous_next: Option<Preview>,
grid_source: Option<Vec<Preview>>,
grid: Vec<Option<egui::TextureHandle>>,
}
impl View {
/// Install the same interface and dynamic fallback fonts as SlideApp before layout.
@@ -175,10 +147,6 @@ impl View {
.collect::<Vec<_>>()
});
for (key, modifiers) in keys {
if snapshot.grid && matches!(key, egui::Key::Space | egui::Key::Enter) {
// A focused thumbnail takes these to choose its slide; the show ignores them while the grid is open.
continue;
}
// Consume once before widgets see Space/Enter, avoiding a second button activation.
ui.input_mut(|i| i.consume_key(modifiers, key));
commands.push(Command::Key {
@@ -232,11 +200,6 @@ impl View {
}
});
ui.separator();
if snapshot.grid {
// All slides opens here, as in the desktop Presenter View; the audience keeps the current slide.
self.grid_ui(ui, snapshot, &mut commands);
return commands;
}
ui.columns(2, |columns| {
let left = &mut columns[0];
if snapshot.ended {
@@ -264,6 +227,9 @@ impl View {
if snapshot.zoom_armed {
left.label("Click the preview to zoom.");
}
if snapshot.grid {
left.label("Choose a slide in the audience window, or press G to return.");
}
let right = &mut columns[1];
right.label("Next");
if let Some(texture) = &self.next {
@@ -294,43 +260,6 @@ impl View {
commands
}
}
impl View {
fn grid_ui(&mut self, ui: &mut egui::Ui, snapshot: &Snapshot, commands: &mut Vec<Command>) {
let slides = snapshot.slides.as_deref().unwrap_or_default();
if self.grid_source.as_deref() != Some(slides) {
self.grid_source = Some(slides.to_vec());
self.grid = slides
.iter()
.enumerate()
.map(|(i, p)| p.image().map(|image| ui.ctx().load_texture(format!("remote-grid-{i}"), image, egui::TextureOptions::LINEAR)))
.collect();
}
ui.heading("All slides");
ui.label("Choose a slide to show it, or press G to return.");
if snapshot.total > crate::show::GRID_CAP {
ui.label(format!("First {} slides", crate::show::GRID_CAP));
}
egui::ScrollArea::vertical().show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
for (i, texture) in self.grid.iter().enumerate() {
let size = egui::vec2(160.0, 90.0);
let label = format!("{}", i + 1);
let label = if i == snapshot.slide { egui::RichText::new(label).strong() } else { egui::RichText::new(label) };
let response = match texture {
Some(t) => ui.add(egui::Button::image_and_text(
egui::Image::new(t).fit_to_exact_size(fit_preview(t.size_vec2(), size.x, size.y)),
label,
)),
None => ui.add_sized(size, egui::Button::new(label)),
};
if response.clicked() {
commands.push(Command::Goto { slide: i });
}
}
});
});
}
}
fn fit_preview(size: egui::Vec2, width: f32, height: f32) -> egui::Vec2 {
size * (width.max(1.0) / size.x.max(1.0)).min(height.max(1.0) / size.y.max(1.0))
}
@@ -353,7 +282,6 @@ mod tests {
zoom_armed: false,
current: Some(Preview { width: 1, height: 1, rgba: vec![255; 4] }),
next: Some(Preview { width: 1, height: 1, rgba: vec![0; 4] }),
slides: None,
}
}
@@ -490,7 +418,6 @@ mod tests {
zoom_armed: false,
current: None,
next: None,
slides: None,
};
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state(
|ui, (view, snapshot, sent): &mut (View, Snapshot, Vec<Command>)| {
@@ -510,30 +437,6 @@ mod tests {
assert!(matches!(h.state().2.last(), Some(Command::Key { name, .. }) if name == egui::Key::Space.name()));
}
#[test]
fn all_slides_opens_in_the_popup_and_picks_a_slide() {
use egui_kittest::kittest::Queryable;
let thumb = Preview { width: 16, height: 9, rgba: vec![200; 16 * 9 * 4] };
let mut snapshot = snapshot();
snapshot.total = 3;
snapshot.grid = true;
snapshot.slides = Some(vec![thumb.clone(), thumb.clone(), thumb]);
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state(
|ui, (view, snapshot, sent): &mut (View, Snapshot, Vec<Command>)| {
sent.extend(view.ui(ui, snapshot));
},
(View::default(), snapshot, Vec::new()),
);
h.run_steps(2);
assert!(h.query_by_label("Notes").is_none(), "the grid replaces the presenter panes, as on desktop (#1866)");
h.get_by_label("3").click();
h.run_steps(2);
assert_eq!(h.state().2, vec![Command::Goto { slide: 2 }]);
h.key_press(egui::Key::Enter);
h.run_steps(2);
assert!(!h.state().2.iter().any(|c| matches!(c, Command::Key { .. })), "Enter is left to the focused thumbnail");
}
#[test]
fn previews_are_bounded_and_bad_dimensions_are_rejected() {
let image = egui::ColorImage::filled([1280, 720], egui::Color32::RED);
-4
View File
@@ -18,7 +18,6 @@ mod presenter;
mod render;
mod ui;
mod zoom;
pub(crate) use zoom::GRID_CAP;
#[cfg(test)]
use media::{on_screen, pointer_on_media_bar, shown_slide_rect, slide_media_bars};
@@ -66,8 +65,6 @@ pub struct Show {
preview_spawns: usize,
remote_preview: Option<crate::presenter::Preview>,
remote_next: Option<(usize, crate::presenter::Preview)>,
/// All slides thumbnails sent to the web presenter window while its grid is open (#1866).
remote_grid: Vec<crate::presenter::Preview>,
remote_sent: f64,
remote_opened: bool,
/// The next slide's first frame, rendered in the background while this one is shown so
@@ -240,7 +237,6 @@ impl Show {
preview_spawns: 0,
remote_preview: None,
remote_next: None,
remote_grid: vec![],
remote_sent: f64::NEG_INFINITY,
remote_opened: false,
prefetch: None,
+2 -39
View File
@@ -1132,8 +1132,6 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
opens: usize,
closes: usize,
commands: Vec<Command>,
last: Option<Snapshot>,
gone: bool,
}
struct Mock(Rc<RefCell<Bridge>>);
impl Host for Mock {
@@ -1142,14 +1140,12 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
Ok(())
}
fn is_open(&self) -> bool {
!self.0.borrow().gone
true
}
fn commands(&mut self) -> Vec<Command> {
std::mem::take(&mut self.0.borrow_mut().commands)
}
fn update(&mut self, snapshot: Snapshot) {
self.0.borrow_mut().last = Some(snapshot);
}
fn update(&mut self, _: Snapshot) {}
fn close(&mut self) {
self.0.borrow_mut().closes += 1;
}
@@ -1177,39 +1173,6 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
h.run_steps(2);
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide);
}
// #1864: the web popup's Previous/Next buttons send the DOM key names.
for (name, slide) in [("ArrowRight", 1), ("ArrowLeft", 0)] {
bridge.borrow_mut().commands.push(Command::Key { name: name.into(), command: false, shift: false, alt: false, ctrl: false });
h.run_steps(2);
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide, "{name}");
}
// #1866: All slides opens in the web Presenter View, as on desktop; the audience keeps its slide.
bridge.borrow_mut().commands.push(Command::Key { name: "G".into(), command: false, shift: false, alt: false, ctrl: false });
h.run_steps(2);
let show = h.state().show.as_ref().unwrap();
assert!(show.presenter_grid && !show.grid, "G from the popup opens its own grid, not the audience's");
for _ in 0..20 {
h.step();
std::thread::sleep(std::time::Duration::from_millis(15));
}
let last = bridge.borrow().last.clone().unwrap();
assert!(last.grid);
assert_eq!(last.slides.as_ref().map(Vec::len), Some(3), "the popup gets a thumbnail per slide");
assert!(last.slides.iter().flatten().all(|p| p.width.max(p.height) <= 96), "thumbnails stay small enough for one message");
assert!(last.current.is_none() && last.next.is_none(), "the grid replaces both previews");
bridge.borrow_mut().commands.push(Command::Goto { slide: 2 });
h.run_steps(2);
let show = h.state().show.as_ref().unwrap();
assert!(!show.presenter_grid && !show.grid);
assert_eq!(show.state.slide, 2);
// Closing the popup with its grid open must not hold auto-advance for the rest of the show.
bridge.borrow_mut().commands.push(Command::Key { name: "G".into(), command: false, shift: false, alt: false, ctrl: false });
h.run_steps(2);
assert!(h.state().show.as_ref().unwrap().presenter_grid);
bridge.borrow_mut().gone = true;
h.run_steps(2);
assert!(!h.state().show.as_ref().unwrap().presenter_grid);
bridge.borrow_mut().gone = false;
bridge.borrow_mut().commands.push(Command::End);
h.run_steps(2);
assert!(h.state().show.is_none());
+5 -37
View File
@@ -80,10 +80,6 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
&& !show.reading
&& let Some(host) = app.services.presenter.as_mut()
{
if show.remote_opened && show.presenter_grid && !host.is_open() {
// A grid left open in the closed popup would hold auto-advance for the rest of the show.
show.presenter_grid = false;
}
if !show.remote_opened {
show.remote_opened = true;
if let Err(message) = host.open() {
@@ -98,20 +94,11 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
show.zoom_arm = false;
}
crate::presenter::Command::Key { name, command, shift, alt, ctrl } => {
if let Some(key) = egui::Key::from_name(&name) {
if let Some(key) = egui::Key::ALL.iter().copied().find(|key| key.name() == name) {
let modifiers = egui::Modifiers { command, shift, alt, ctrl, mac_cmd: command && !ctrl };
// The popup is the Presenter View: G opens All slides there, not on the audience screen (#1866).
out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], true).0;
out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], false).0;
}
}
crate::presenter::Command::Goto { slide } if show.presenter_grid && slide < doc.slides.len() => {
show.presenter_grid = false;
show.blank = None;
show.zoom = None;
show.zoom_arm = false;
let action = show.state.goto(&doc, slide);
out.keep &= show.apply(action, &doc, now);
}
_ => {}
}
}
@@ -156,20 +143,6 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
);
show.remote_next = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false)).map(|image| (n, image));
}
if !show.presenter_grid {
show.remote_grid.clear();
} else if show.remote_grid.len() < grid_indices(doc.slides.len()).len() {
// A few thumbnails per update keep the audience window responsive on the web.
for i in grid_indices(doc.slides.len()).into_iter().skip(show.remote_grid.len()).take(4) {
// At most 96 px on the long side: 48 thumbnails stay well under the popup's message limit.
let (w, h) = (doc.slide_size.width.max(1.0), doc.slide_size.height.max(1.0));
let scale = REMOTE_THUMB / w.max(h);
let size = (((w * scale).round() as u32).max(1), ((h * scale).round() as u32).max(1));
let image = deckcraft_render::render_slide(&doc, i, &RenderOpts { scale, size: Some(size), ..Default::default() });
let preview = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false));
show.remote_grid.push(preview.unwrap_or(crate::presenter::Preview { width: 1, height: 1, rgba: vec![0; 4] }));
}
}
host.update(crate::presenter::Snapshot {
slide: show.state.slide,
total: doc.slides.len(),
@@ -179,12 +152,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
paused: show.timer.paused(),
notes_px: show.notes_px,
blank: show.blank_name().map(str::to_owned),
grid: show.presenter_grid,
grid: show.grid,
zoom_armed: show.zoom_arm,
// The grid replaces both previews in the popup; leaving them out keeps one message small.
current: show.remote_preview.clone().filter(|_| !show.presenter_grid),
next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next && !show.presenter_grid).map(|(_, image)| image.clone()),
slides: show.presenter_grid.then(|| show.remote_grid.clone()),
current: show.remote_preview.clone(),
next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next).map(|(_, image)| image.clone()),
});
show.remote_sent = now;
}
@@ -193,9 +164,6 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
finish(app, show, &f, out.keep);
}
/// Longest side of an All slides thumbnail sent to the web presenter window (#1866).
const REMOTE_THUMB: f64 = 96.0;
fn paint_hidden(ui: &Ui, doc: &Presentation, show: &Show) {
if show.started {
let full = ui.max_rect();
+1 -1
View File
@@ -182,7 +182,7 @@ const WHEEL_NOTCHES_PER_DOUBLING: f32 = 4.0;
/// PowerPoint's slide show magnifier goes up to 400%.
const WHEEL_ZOOM_MAX: f32 = 4.0;
pub(crate) const GRID_CAP: usize = 48;
pub(super) const GRID_CAP: usize = 48;
const TOOL_BAR_W: f32 = 520.0;
+1 -7
View File
@@ -18,13 +18,7 @@ fn walk(dir: &Path, c: &mut Counts) {
let p = e.path();
let name = e.file_name();
let name = name.to_string_lossy();
// `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() {
if p.is_dir() {
// Nested fuzz workspaces and build output are not part of the crate.
if name != "target" && name != "fuzz" && !name.starts_with('.') {
walk(&p, c);