Merge upstream changes into parallel image decode

This commit is contained in:
Dylan M. Taylor committed 2026-11-12 17:19:59 -05:00
commit a656db2be4
28 files changed
+1613 -16

No files matched your search

Generated
+7
View File
@@ -1504,6 +1504,7 @@ dependencies = [
"designcraft-fonts",
"flate2",
"image",
"jpeg-decoder",
"log",
"quick-xml 0.41.0",
"resvg",
@@ -3306,6 +3307,12 @@ dependencies = [
"libc",
]
[[package]]
name = "jpeg-decoder"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
[[package]]
name = "js-sys"
version = "0.3.106"
+2
View File
@@ -397,6 +397,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
- **2026-11-12** · A render decodes the placed images in view that are not cached yet on several threads before it draws, instead of one after another while drawing. Page thumbnails, image exports and the first canvas frame of a page with six 24 MP photos take about 1.2 s instead of 2.8 s. No readiness percentage change.
- **2026-11-12** · An edit of a long threaded story composes the paragraphs it can change and copies the other lines from the composition before the edit; every typed character composed the whole story. In a 240-paragraph story a typed character composes 3 paragraphs (240 before). An edit that moves lines into other frames composes on until a paragraph starts in the same frame and place as before, or to the story's end, and stories with footnotes, tables, anchored objects, balanced columns or keep options that move lines are composed whole. No readiness percentage change.
- **2026-11-12** · Type shortcuts: Justify All Lines ⇧⌘F, Insert White Space › Em Space ⇧⌘M and En Space ⇧⌘N (shown in the menu, enabled at an insertion point), Paragraph Rules… ⌥⌘J (shown in the Paragraph panel menu). On the Mac a menu-bar insert (Em/En Space, Current Page Number) no longer types into the document while a text field has focus, a dialog or Quick Apply is open, or an input method is composing. An alignment the paragraphs already have records no undo step (#1138). No readiness percentage change.
- **2026-11-12** · A placed JPEG drawn at a half, a quarter or an eighth of its pixel size or less is decoded at that size straight from its compressed blocks, so the canvas, page thumbnails and loaded-graphic thumbnails no longer decode and keep the full-size picture for it. CMYK JPEGs and the full size still decode in full. No readiness percentage change.
- **2026-11-12** · A decoded placed image becomes a screen pixmap in its own buffer, premultiplied in place, and opaque pixels are left as they are: 120 ms becomes 7 ms for a 24-megapixel opaque photo, with the same pixels. No readiness percentage change.
- **2026-11-12** · The status bar's preflight count and the Preflight panel take their issues from a check on a background worker, which checks the newest state of the document and shows the issues last found meanwhile. Moving or resizing a frame and typing no longer wait for a check of the whole document on every drag step and keystroke. No readiness percentage change.
- **2026-11-12** · Object › Text Frame Options… (Cmd+B) opens with the Type tool in a frame, so inset spacing and vertical justification can be changed while typing; it changes the frame that shows the insertion point, or every frame that holds part of the selected text (#2396). No readiness percentage change.
@@ -435,6 +436,7 @@ Newest first.
- **2026-10-28** · IDML export writes the document's default `StoryPreference` in `Resources/Preferences.xml`, as InDesign does. Only each story carried one, and IDML readers that take the spread reading order from the document's (Okapi's IDML filter) failed on DesignCraft's files. No readiness percentage change.
- **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change.
- **2026-10-26** · Inner Shadow, Inner Glow (from the edges) and Bevel and Emboss on a frame holding an image follow what the image paints: a transparent part is outside the object, so the effect lines the image's opaque edge, not the frame (#1622). Satin and a centred Inner Glow still follow the frame. No readiness percentage change.
- **2026-10-25** · Duplicate Layer (`layer.duplicate`; the layer's context menu in the Layers panel, or a layer dropped on Create New Layer) adds a copy named after the layer with " copy" added ("Art copy" for Art) above the layer, with its settings, a copy of every object on it and copies of its ruler guides, on document and parent pages, in one undo step. Copied frames stay threaded to the copies of frames on the same layer and are not threaded to frames on other layers. A copied override of a parent item overrides that item's copy, and pages hide the copy where they hide the parent item. Hyperlinks on copied objects and text get copies that go to the same destinations. No readiness percentage change.
- **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change.
- **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change.
- **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change.
File diff suppressed because it is too large. Load diff
+10 -1
View File
@@ -1,4 +1,4 @@
use super::layers::set_layer;
use super::layers::{duplicate_layer, set_layer};
use super::margins::{adjust_layout, column_guides_unlocked, margin_boxes, move_column_guide, set_margins_columns, set_page_width};
use super::pages::{document_setup, page_options, page_size};
use super::parents::{
@@ -713,6 +713,15 @@ pub fn specs() -> Vec<CommandSpec> {
s.doc_mut()?.active_layer = r;
Ok(json!({"id": r.0}))
}),
cmd!(
"layer.duplicate",
"Duplicate Layer",
[],
None,
"{id} → {id}. Adds a copy of the layer's settings and objects (document and parent spreads) above it, named \"<name> copy\"",
has_doc,
duplicate_layer
),
cmd!(
"layer.set",
"Layer Options…",
+73 -9
View File
@@ -1281,19 +1281,41 @@ fn retarget_xrefs(copy: &mut Story, orig: &Story, map: &HashMap<u64, u64>, depth
}
}
/// Fresh ids for the objects anchored in story copy `st`, its table cells, footnotes and endnotes (#1522), each `depth`
/// levels deep. False when one of them nests groups deeper than [`MAX_COPY_DEPTH`].
/// Fresh ids for the objects anchored in story copy `st`, its table cells, footnotes and endnotes down to
/// [`MAX_COPY_DEPTH`] levels (#1522), each `depth` levels deep. False when one of them nests groups deeper than
/// [`MAX_COPY_DEPTH`], or when the stories below that level hold anchored objects, tables or notes.
#[must_use = "false means the copy is incomplete and must not be inserted"]
fn renumber_anchored(dst: &mut Document, src: &Document, st: &mut Story, stories: &mut HashMap<StoryId, StoryId>, depth: usize) -> bool {
renumber_anchored_in(dst, src, st, stories, depth, 0)
}
/// [`renumber_anchored`] for a story `level` table cells and notes below the story copy.
#[must_use = "false means the copy is incomplete and must not be inserted"]
fn renumber_anchored_in(
dst: &mut Document,
src: &Document,
st: &mut Story,
stories: &mut HashMap<StoryId, StoryId>,
depth: usize,
level: usize,
) -> bool {
let mut complete = true;
let mut fresh = |t: &mut Story| {
for o in &mut t.objects {
complete &= renumber_in(dst, src, &mut Arc::make_mut(o).item, stories, depth, false);
for o in &mut st.objects {
complete &= renumber_in(dst, src, &mut Arc::make_mut(o).item, stories, depth, false);
}
if level >= MAX_COPY_DEPTH {
let leaf = |s: &Story| s.objects.is_empty() && s.tables.is_empty() && s.notes.is_empty() && s.endnotes.is_empty();
return complete
&& st.tables.values().all(|t| t.cells.iter().all(|c| leaf(&c.text)))
&& st.notes.iter().chain(&st.endnotes).all(|n| leaf(&n.text));
}
for t in st.tables.values_mut() {
for c in &mut Arc::make_mut(t).cells {
complete &= renumber_anchored_in(dst, src, &mut c.text, stories, depth, level + 1);
}
};
st.for_each_text_mut(&mut fresh);
for n in &mut st.endnotes {
Arc::make_mut(n).text.for_each_text_mut(&mut fresh);
}
for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
complete &= renumber_anchored_in(dst, src, &mut Arc::make_mut(n).text, stories, depth, level + 1);
}
complete
}
@@ -6105,6 +6127,48 @@ mod copy_ids_tests {
assert_eq!(s.doc().unwrap().doc, before, "nothing is pasted");
}
#[test]
fn objects_anchored_in_cells_nested_too_deeply_are_not_copied() {
use std::sync::Arc;
// The first cell of the story's table holds `n` tables, each in the first cell of the one before, and the cell
// of the innermost one holds an anchored object.
fn nested(n: usize) -> crate::Session {
let mut s = crate::Session::new();
let (f, sid) = marked_frame(&mut s);
let leaf = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap();
let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let item = d.item(designcraft_doc::ItemId(leaf)).cloned().unwrap();
Arc::make_mut(&mut d.spreads[0]).items.retain(|i| i.id.0 != leaf);
let mut chain: Option<designcraft_doc::Table> = None;
for _ in 0..n {
let mut t = designcraft_doc::Table::new(d.alloc(), 1, 1, 0, 0, 40.0);
match chain.take() {
Some(inner) => {
t.cells[0].text.insert_table(0, inner);
}
None => {
t.cells[0].text.insert_object(0, designcraft_doc::AnchoredObject::new(item.clone(), Default::default()));
}
}
chain = Some(t);
}
let st = d.story_mut(sid).unwrap();
let text = &mut Arc::make_mut(st.tables.values_mut().next().unwrap()).cells[0].text;
text.insert_table(text.len(), chain.unwrap());
s.execute("selection.set", &json!({"ids": [f]})).unwrap();
s
}
let mut s = nested(super::MAX_COPY_DEPTH - 1);
s.execute("edit.duplicate", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.anchored_items().len(), 2, "the anchored object is copied");
let mut s = nested(super::MAX_COPY_DEPTH);
let before = s.doc().unwrap().doc.clone();
let e = s.execute("edit.duplicate", &json!({})).unwrap_err().to_string();
assert!(e.contains("too deeply to copy"), "{e}");
assert_eq!(s.doc().unwrap().doc, before, "nothing is copied");
}
#[test]
fn pasted_cross_references_follow_the_pasted_anchors_of_other_stories() {
let mut s = crate::Session::new();
+2
View File
@@ -21,6 +21,8 @@ log = { workspace = true }
craft-cms = { workspace = true }
zune-core = { version = "0.5", default-features = false }
zune-jpeg = "0.5"
# Reduced-size JPEG decoding (1/2, 1/4, 1/8 through the DCT) for screen proxies.
jpeg-decoder = { version = "0.3", default-features = false }
[dev-dependencies]
designcraft-fonts = { workspace = true, features = ["testing"] }
+22
View File
@@ -241,3 +241,25 @@ fn unusable_profiles_leave_the_pixels_alone() {
let png = png_with_icc(1, 1, &px, Some(vec![0xAB; 300]));
assert_eq!(decode_rgba(&png).unwrap().as_raw(), &px);
}
#[test]
fn reduced_jpegs_get_the_colour_handling_of_full_decodes() {
// An Adobe RGB JPEG: the reduced decode applies the profile as the full decode does.
let img = image::RgbImage::from_pixel(64, 48, image::Rgb([64, 192, 64]));
let mut jpeg = Vec::new();
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 95);
enc.set_icc_profile(adobe_rgb_icc()).unwrap();
enc.write_image(img.as_raw(), 64, 48, image::ExtendedColorType::Rgb8).unwrap();
let full = decode_rgba(&jpeg).unwrap().get_pixel(3, 3).0;
let reduced = crate::decode_rgba_reduced(&jpeg, 3).unwrap();
assert_eq!(reduced.dimensions(), (8, 6));
let p = reduced.get_pixel(1, 1).0;
for c in 0..4 {
assert!(p[c].abs_diff(full[c]) <= 2, "{p:?} vs {full:?}");
}
let want = adobe_to_srgb([64, 192, 64]);
assert!((f32::from(p[0]) - want[0]).abs() <= 3.0, "{p:?} vs {want:?}");
// CMYK and YCCK JPEGs stay with the full decode, which converts their inks.
assert!(crate::decode_rgba_reduced(&solid_jpeg([0, 255, 255, 255], Some(0)), 1).is_none());
assert!(crate::decode_rgba_reduced(&solid_jpeg([76, 85, 255, 255], Some(2)), 1).is_none());
}
+62
View File
@@ -62,6 +62,68 @@ pub(crate) fn decode_cmyk(bytes: &[u8]) -> Option<(CmykRaster, Option<Vec<u8>>)>
Some((raster, icc))
}
/// The largest reduction [`decode_reduced`] reads straight from a JPEG's 8×8 blocks: 1/8.
pub(crate) const MAX_SHRINK: u8 = 3;
/// A YCbCr, RGB or grayscale JPEG decoded at 1/2^`shrink` of its size, each side rounded up, for
/// `shrink` 1 to [`MAX_SHRINK`]: straight RGBA and whether the image has colour. `None` for CMYK,
/// 12-bit and lossless JPEGs, other formats and anything that doesn't decode. `None` as well for a
/// JPEG whose full size is over the full decode's limit, and for a progressive JPEG whose
/// full-size coefficients take more than [`MAX_BYTES`]; the full decode applies its own limits to
/// those.
pub(crate) fn decode_reduced(bytes: &[u8], shrink: u8) -> Option<(image::RgbaImage, bool)> {
use jpeg_decoder::{CodingProcess, PixelFormat};
if !bytes.starts_with(&[0xFF, 0xD8]) || shrink == 0 || shrink > MAX_SHRINK {
return None;
}
let mut d = jpeg_decoder::Decoder::new(bytes);
// This limits the output only, which is the reduced size.
d.set_max_decoding_buffer_size(MAX_BYTES);
d.read_info().ok()?;
let info = d.info()?;
if crate::over_rgba_budget(u32::from(info.width), u32::from(info.height)) {
return None;
}
let colour = match info.pixel_format {
PixelFormat::RGB24 => true,
PixelFormat::L8 => false,
PixelFormat::L16 | PixelFormat::CMYK32 => return None,
};
if info.coding_process == CodingProcess::DctProgressive {
// A progressive JPEG holds every coefficient of the full-size image (two bytes each) until
// its last scan, whatever the output size. Each component is counted at the full size and
// each side is rounded up to the largest MCU (32 pixels): an upper bound.
let side = |n: u16| u64::from(n).next_multiple_of(32);
let components: u64 = if colour { 3 } else { 1 };
if side(info.width) * side(info.height) * components * 2 > MAX_BYTES as u64 {
return None;
}
}
let reduced = |n: u16| u16::try_from(u32::from(n).div_ceil(1 << shrink)).ok();
let expected = (reduced(info.width)?, reduced(info.height)?);
// The decoder takes the smallest scale that meets either requested side, and a short side
// rounds up to the same size at several scales, so only the long side is requested.
let request = if info.width >= info.height { (expected.0, u16::MAX) } else { (u16::MAX, expected.1) };
let (w, h) = d.scale(request.0, request.1).ok()?;
if (w, h) != expected {
return None;
}
let px = d.decode().ok()?;
let pixels = usize::from(w).checked_mul(usize::from(h))?;
let rgba: Vec<u8> = if colour {
if px.len() != pixels.checked_mul(3)? {
return None;
}
px.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect()
} else {
if px.len() != pixels {
return None;
}
px.iter().flat_map(|&v| [v, v, v, 255]).collect()
};
Some((image::RgbaImage::from_raw(u32::from(w), u32::from(h), rgba)?, colour))
}
/// Whether an APP14 "Adobe" segment comes before the image data.
fn adobe_marker(bytes: &[u8]) -> bool {
let mut i = 2;
+27 -1
View File
@@ -416,7 +416,12 @@ const MAX_RGBA_BYTES: u64 = 1 << 30;
/// Whether a raster's header gives it more pixels than [`decode_rgba`] decodes.
pub fn too_large_to_decode(bytes: &[u8]) -> bool {
// `image` limits only its own output, which can be a quarter of the RGBA copy or less.
pixel_size(bytes).is_some_and(|(w, h)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4) > MAX_RGBA_BYTES)
pixel_size(bytes).is_some_and(|(w, h)| over_rgba_budget(w, h))
}
/// Whether a `w` × `h` raster takes more than [`MAX_RGBA_BYTES`] as RGBA.
fn over_rgba_budget(w: u32, h: u32) -> bool {
u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4) > MAX_RGBA_BYTES
}
/// Straight (non-premultiplied) RGBA8 of a raster in display sRGB: an embedded ICC profile is
@@ -484,6 +489,27 @@ pub fn decode_bytes_per_pixel(bytes: &[u8]) -> usize {
(sample + 4).max(8)
}
/// The largest `shrink` [`decode_rgba_reduced`] takes: 3, an eighth of each side.
pub const MAX_REDUCED_SHRINK: u8 = jpeg::MAX_SHRINK;
/// [`decode_rgba`] of a JPEG at 1/2^`shrink` of its size (`shrink` 1 to [`MAX_REDUCED_SHRINK`], each
/// side rounded up), read from its compressed blocks without a full-size decode. The embedded ICC
/// profile is applied as [`decode_rgba`] applies it. `None` where only a full decode applies: other
/// formats, CMYK, 12-bit and lossless JPEGs, files whose size [`pixel_size`] cannot read, and files
/// [`decode_rgba`] refuses as too large.
pub fn decode_rgba_reduced(bytes: &[u8], shrink: u8) -> Option<image::RgbaImage> {
use colour::{DisplayColour, Pixels, to_display_rgba};
// The size check must have a size to check: a header only the JPEG decoder reads is refused.
pixel_size(bytes)?;
if too_large_to_decode(bytes) {
return None;
}
let (rgba, has_colour) = jpeg::decode_reduced(bytes, shrink)?;
let Some(icc) = embedded_icc(bytes) else { return Some(rgba) };
let px = if has_colour { Pixels::Rgb(rgba) } else { Pixels::Gray(rgba) };
to_display_rgba(px, Some(&icc), &DisplayColour::active())
}
/// False when `bytes` is a WebP whose `ICCP` chunk declares more bytes than the file holds or than
/// [`colour::MAX_ICC_BYTES`]. The decoder sizes its buffer from the declared length before it reads
/// anything, so such a file must not reach `icc_profile()`; its pixels decode without it.
+121
View File
@@ -912,3 +912,124 @@ fn a_webp_icc_chunk_that_declares_more_than_it_holds_is_refused_before_decoding(
assert!(webp_icc_is_sane(b"\x89PNG not a webp"));
assert!(webp_icc_is_sane(&webp_with(&[(b"VP8L", 6, &[0; 6])])));
}
/// A JPEG of a smooth gradient, `w` × `h`.
fn gradient_jpeg(w: u32, h: u32, gray: bool) -> Vec<u8> {
let rgb = image::RgbImage::from_fn(w, h, |x, y| image::Rgb([(x * 255 / w) as u8, (y * 255 / h) as u8, 128]));
let img = if gray { image::DynamicImage::ImageRgb8(rgb).into_luma8().into() } else { image::DynamicImage::ImageRgb8(rgb) };
let mut out = Vec::new();
img.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 90)).unwrap();
out
}
#[test]
fn reduced_jpegs_decode_at_a_half_quarter_or_eighth_rounded_up() {
let jpeg = gradient_jpeg(1001, 77, false);
let full = decode_rgba(&jpeg).unwrap();
for (shrink, size) in [(1, (501, 39)), (2, (251, 20)), (3, (126, 10))] {
let img = decode_rgba_reduced(&jpeg, shrink).unwrap();
assert_eq!(img.dimensions(), size, "shrink {shrink}");
// The same picture as the full decode sampled at the block centres, give or take rounding.
let step = 1u32 << shrink;
let (mut total, mut n) = (0u64, 0u64);
for y in 0..size.1 - 1 {
for x in 0..size.0 - 1 {
let (p, q) = (img.get_pixel(x, y).0, full.get_pixel(x * step + step / 2, y * step + step / 2).0);
total += (0..3).map(|c| u64::from(p[c].abs_diff(q[c]))).sum::<u64>();
n += 3;
assert_eq!(p[3], 255);
}
}
assert!(total / n <= 3, "shrink {shrink}: mean difference {}", total / n);
}
// Only 1/2, 1/4 and 1/8 are read from the blocks.
assert!(decode_rgba_reduced(&jpeg, 0).is_none());
assert!(decode_rgba_reduced(&jpeg, 4).is_none());
}
#[test]
fn a_thin_reduced_jpeg_keeps_its_long_side_at_the_requested_reduction() {
// The short side rounds up to 1 at every reduction, so it cannot pick the scale.
for (w, h) in [(100, 2), (2, 100)] {
let jpeg = gradient_jpeg(w, h, false);
for (shrink, long) in [(1, 50), (2, 25), (3, 13)] {
let img = decode_rgba_reduced(&jpeg, shrink).unwrap();
let size = if w > h { (long, 1) } else { (1, long) };
assert_eq!(img.dimensions(), size, "{w} × {h}, shrink {shrink}");
}
}
}
#[test]
fn reduced_decoding_is_for_jpegs_and_survives_damaged_ones() {
let gray = decode_rgba_reduced(&gradient_jpeg(64, 64, true), 2).unwrap();
assert_eq!(gray.dimensions(), (16, 16));
assert!(gray.pixels().all(|p| p[0] == p[1] && p[1] == p[2] && p[3] == 255));
let png = png_from_rgba(16, 16, vec![255; 16 * 16 * 4]).unwrap();
assert!(decode_rgba_reduced(&png, 1).is_none());
assert!(decode_rgba_reduced(&[0xFF, 0xD8], 1).is_none());
let jpeg = gradient_jpeg(200, 150, false);
for n in (0..jpeg.len()).step_by(37) {
if let Some(img) = decode_rgba_reduced(&jpeg[..n], 1) {
assert_eq!(img.dimensions(), (100, 75));
}
}
}
#[test]
fn reduced_decoding_refuses_a_jpeg_whose_size_the_size_check_cannot_read() {
let jpeg = gradient_jpeg(64, 48, false);
assert_eq!(decode_rgba_reduced(&jpeg, 1).unwrap().dimensions(), (32, 24));
// A stray byte after the start marker: the JPEG decoder skips it, but `pixel_size` doesn't
// recognize the file, so `too_large_to_decode` has no size to check.
let mut stray = jpeg.clone();
stray.insert(2, 0);
assert_eq!(pixel_size(&stray), None);
assert!(decode_rgba_reduced(&stray, 1).is_none());
assert!(decode_rgba(&stray).is_none());
}
/// A progressive JPEG with only its DC scan, `w` × `h` with `components` 1×1-sampled components:
/// every coefficient is zero (a flat mid-gray image).
fn progressive_jpeg(w: u16, h: u16, components: u8) -> Vec<u8> {
let mut out = vec![0xFF, 0xD8];
// One quantization table of ones.
out.extend([0xFF, 0xDB, 0, 67, 0]);
out.extend([1; 64]);
// Start of frame, progressive DCT.
out.extend([0xFF, 0xC2, 0, 8 + 3 * components, 8]);
out.extend(h.to_be_bytes());
out.extend(w.to_be_bytes());
out.push(components);
for id in 1..=components {
out.extend([id, 0x11, 0]);
}
// A DC table with one code, "0", for a difference of 0.
out.extend([0xFF, 0xC4, 0, 20, 0x00, 1]);
out.extend([0; 15]);
out.push(0);
// The DC scan of every component (Ss, Se, Ah/Al all 0), then zero bits.
out.extend([0xFF, 0xDA, 0, 6 + 2 * components, components]);
for id in 1..=components {
out.extend([id, 0x00]);
}
out.extend([0, 0, 0]);
out.extend([0; 16]);
out.extend([0xFF, 0xD9]);
out
}
#[test]
fn reduced_decoding_refuses_progressive_jpegs_whose_full_size_coefficients_are_over_the_limit() {
let small = decode_rgba_reduced(&progressive_jpeg(64, 48, 3), 3).unwrap();
assert_eq!(small.dimensions(), (8, 6));
// 10,240 × 8,800 is within the RGBA limit, but its three components' coefficients take
// 10,240 × 8,800 × 3 × 2 bytes, over 512 MiB, whatever the output size.
let large = progressive_jpeg(10_240, 8_800, 3);
assert_eq!(pixel_size(&large), Some((10_240, 8_800)));
assert!(!too_large_to_decode(&large));
assert!(decode_rgba_reduced(&large, 3).is_none());
// One component of the same size stays under the limit.
let gray = decode_rgba_reduced(&progressive_jpeg(10_240, 8_800, 1), 3).unwrap();
assert_eq!(gray.dimensions(), (1_280, 1_100));
}
+11 -1
View File
@@ -68,7 +68,17 @@ pub fn decode_pixmap_page(bytes: &[u8], page: u32) -> Option<Pixmap> {
let (px, w, h) = designcraft_images::render_svg(bytes, VECTOR_SIDE)?;
return Some(Pixmap::from_parts(premul_pixels(px), w.min(u16::MAX as u32) as u16, h.min(u16::MAX as u32) as u16));
}
let img = designcraft_images::decode_rgba(bytes)?;
premultiplied(designcraft_images::decode_rgba(bytes)?)
}
/// [`decode_pixmap`] of a JPEG at 1/2^`shrink` of its size, `shrink` 1 to
/// [`designcraft_images::MAX_REDUCED_SHRINK`], without a full-size decode. `None` where only a
/// full decode applies (other formats, CMYK JPEGs).
pub(crate) fn decode_pixmap_reduced(bytes: &[u8], shrink: u8) -> Option<Pixmap> {
premultiplied(designcraft_images::decode_rgba_reduced(bytes, shrink)?)
}
fn premultiplied(img: image::RgbaImage) -> Option<Pixmap> {
let (w, h) = img.dimensions();
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 {
return None;
+125 -1
View File
@@ -11,7 +11,7 @@ use std::sync::{Arc, Condvar, Mutex};
use vello_cpu::Pixmap;
use crate::decode::decode_sizes;
use crate::decode::{decode_pixmap_reduced, decode_sizes};
use crate::{decode_pixmap_page, halve};
/// Decoded pixels kept (bytes).
@@ -135,6 +135,11 @@ pub fn decoded(bytes: &Arc<Vec<u8>>, page: u32) -> Option<Arc<Pixmap>> {
/// The mip level of `bytes` closest above `on_screen` device pixels per point for a graphic
/// `width_pt` points wide.
pub fn mip(bytes: &Arc<Vec<u8>>, page: u32, width_pt: f64, on_screen: f64) -> Option<Arc<Pixmap>> {
if lookup((Arc::as_ptr(bytes) as usize, page, 0)).is_none()
&& let Some(pm) = reduced(bytes, page, width_pt, on_screen)
{
return Some(pm);
}
let full = decoded(bytes, page)?;
let ppt = full.width() as f64 / width_pt.max(1e-6);
let id = Arc::as_ptr(bytes) as usize;
@@ -327,6 +332,71 @@ fn waited(job: &[Want]) {
WAITED.1.notify_all();
}
/// [`mip`]'s level for a JPEG, decoded at 1/2, 1/4 or 1/8 of its size straight from its blocks
/// and halved from there, so a screen-sized level needs no full-size decode. `None` when the
/// full size is drawn, for other formats and for JPEGs only a full decode reads (CMYK).
fn reduced(bytes: &Arc<Vec<u8>>, page: u32, width_pt: f64, on_screen: f64) -> Option<Arc<Pixmap>> {
if page != 0 || !bytes.starts_with(&[0xFF, 0xD8]) {
return None;
}
let id = Arc::as_ptr(bytes) as usize;
if with(|c| c.bad.contains_key(&(id, page))) {
return None;
}
// The level `mip` picks from a full decode, with each level's size as `halve` gives it.
let (mut w, mut h) = crate::image_size(bytes)?;
let ppt = w as f64 / width_pt.max(1e-6);
let mut sizes = vec![(w, h)];
while ppt / (1u64 << sizes.len()) as f64 >= on_screen * 1.2 && w > 64 && h > 64 && sizes.len() <= 8 {
(w, h) = ((w / 2).max(1), (h / 2).max(1));
sizes.push((w, h));
}
let level = u8::try_from(sizes.len() - 1).ok()?;
if level == 0 {
return None;
}
if let Some(pm) = lookup((id, page, level)) {
return Some(pm);
}
let shrink = level.min(designcraft_images::MAX_REDUCED_SHRINK);
let mut pm = match lookup((id, page, shrink)) {
Some(p) => p,
None => {
// Cut to `halve`'s size (it rounds down, the decoder up), as the level a full
// decode makes.
let (sw, sh) = sizes[usize::from(shrink)];
let p = Arc::new(crop(decode_pixmap_reduced(bytes, shrink)?, sw, sh)?);
store(bytes, page, shrink, p.clone());
p
}
};
for l in shrink + 1..=level {
pm = match lookup((id, page, l)) {
Some(p) => p,
None => {
let half = Arc::new(halve(&pm));
store(bytes, page, l, half.clone());
half
}
};
}
Some(pm)
}
/// The top-left `w` × `h` of `pm`; `None` when `pm` is smaller.
fn crop(pm: Pixmap, w: u32, h: u32) -> Option<Pixmap> {
let (pw, ph) = (u32::from(pm.width()), u32::from(pm.height()));
if (pw, ph) == (w, h) {
return Some(pm);
}
if w > pw || h > ph || w == 0 || h == 0 {
return None;
}
let (pw, w, h) = (pw as usize, w as usize, h as usize);
let data: Vec<_> = pm.data().chunks_exact(pw).take(h).flat_map(|row| &row[..w]).copied().collect();
Some(Pixmap::from_parts(data, u16::try_from(w).ok()?, u16::try_from(h).ok()?))
}
/// Drop every decoded image.
pub fn clear() {
with(|c| {
@@ -497,4 +567,58 @@ mod tests {
assert_eq!((decoders(&a, decoded_since).len(), decoders(&b, decoded_since).len()), (1, 1));
assert!(peak > 0 && peak <= BUDGET / 2, "{peak} bytes decoding at once");
}
fn jpeg(w: u32, h: u32) -> Arc<Vec<u8>> {
let img = image::RgbImage::from_fn(w, h, |x, y| image::Rgb([(x * 255 / w) as u8, (y * 255 / h) as u8, 128]));
let mut out = Vec::new();
img.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 90)).unwrap();
Arc::new(out)
}
#[test]
fn screen_levels_of_a_jpeg_need_no_full_size_decode() {
let bytes = jpeg(1024, 768);
let id = Arc::as_ptr(&bytes) as usize;
// 1 px per point drawn at 0.1: level 3, an eighth, read straight from the blocks.
let pm = mip(&bytes, 0, 1024.0, 0.1).unwrap();
assert_eq!((pm.width(), pm.height()), (128, 96));
assert!(lookup((id, 0, 0)).is_none(), "no full-size decode is kept");
// Close to the full decode halved three times.
let mut want = decode_pixmap_page(&bytes, 0).unwrap();
for _ in 0..3 {
want = halve(&want);
}
let diff: u64 = pm.data().iter().zip(want.data()).map(|(p, q)| u64::from(p.r.abs_diff(q.r)) + u64::from(p.g.abs_diff(q.g))).sum();
assert!(diff / (2 * 128 * 96) <= 3, "mean difference {}", diff / (2 * 128 * 96));
// Smaller levels are halved from that eighth, down to 64 px as before.
let small = mip(&bytes, 0, 1024.0, 0.02).unwrap();
assert_eq!((small.width(), small.height()), (64, 48));
assert!(lookup((id, 0, 0)).is_none(), "no full-size decode is kept");
// The full size is still a full decode.
let full = mip(&bytes, 0, 1024.0, 1.0).unwrap();
assert_eq!((full.width(), full.height()), (1024, 768));
}
#[test]
fn reduced_levels_of_odd_sized_jpegs_have_the_halved_size() {
let bytes = jpeg(1001, 771);
let mut want = decode_pixmap_page(&bytes, 0).unwrap();
for _ in 0..3 {
want = halve(&want);
}
let pm = mip(&bytes, 0, 1001.0, 0.1).unwrap();
assert!(lookup((Arc::as_ptr(&bytes) as usize, 0, 0)).is_none());
assert_eq!((pm.width(), pm.height()), (want.width(), want.height()));
assert_eq!((pm.width(), pm.height()), (125, 96));
}
#[test]
fn a_jpeg_that_does_not_decode_is_remembered_as_failed() {
let mut broken = jpeg(256, 256).to_vec();
broken.truncate(40);
let bytes = Arc::new(broken);
assert!(mip(&bytes, 0, 256.0, 0.1).is_none());
assert!(with(|c| c.bad.contains_key(&(Arc::as_ptr(&bytes) as usize, 0))));
assert!(mip(&bytes, 0, 256.0, 0.1).is_none());
}
}
+1
View File
@@ -1956,6 +1956,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Color Group Options…", "خيارات مجموعة الألوان…"),
("Layer Options", "خيارات الطبقة"),
("Layer Options for \"{name}\"…", "خيارات الطبقة \"{name}\"…"),
("Duplicate Layer \"{name}\"", "تكرار الطبقة \"{name}\""),
("Swatch Name:", "اسم حامل اللون:"),
("Name with Color Value", "التسمية بقيمة اللون"),
("Color Type:", "نوع اللون:"),
+1
View File
@@ -324,6 +324,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Delete Column", "Odstranit sloupec"),
("Delete Condition", "Odstranit podmínku"),
("Delete Layer", "Odstranit vrstvu"),
("Duplicate Layer \"{name}\"", "Duplikovat vrstvu „{name}“"),
("Delete Page", "Odstranit stránku"),
("Delete Row", "Odstranit řádek"),
("Delete Swatch", "Odstranit vzorek"),
+1
View File
@@ -2105,6 +2105,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Color Group Options…", "Farbgruppenoptionen …"),
("Layer Options", "Ebenenoptionen"),
("Layer Options for \"{name}\"…", "Ebenenoptionen für „{name}“ …"),
("Duplicate Layer \"{name}\"", "Ebene „{name}“ duplizieren"),
("Swatch Name:", "Farbfeldname:"),
("Name with Color Value", "Name mit Farbwert"),
("Color Type:", "Farbtyp:"),
+1
View File
@@ -865,6 +865,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Last Page", "Utolsó oldal"),
("Last Spread", "Utolsó oldalpár"),
("Layer Options…", "Réteg beállításai…"),
("Duplicate Layer \"{name}\"", "„{name}” réteg duplikálása"),
("Layers", "Rétegek"),
("Layout", "Elrendezés"),
("Layout/Index", "Elrendezés/Tárgymutató"),
+1
View File
@@ -2016,6 +2016,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Color Group Options…", "Opzioni gruppo colori…"),
("Layer Options", "Opzioni livello"),
("Layer Options for \"{name}\"…", "Opzioni livello \"{name}\"…"),
("Duplicate Layer \"{name}\"", "Duplica livello \"{name}\""),
("Swatch Name:", "Nome campione:"),
("Name with Color Value", "Nome con valore colore"),
("Color Type:", "Tipo colore:"),
+1
View File
@@ -2183,6 +2183,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Color Group Options…", "カラーグループオプション…"),
("Layer Options", "レイヤーオプション"),
("Layer Options for \"{name}\"…", "「{name}」のレイヤーオプション…"),
("Duplicate Layer \"{name}\"", "レイヤー「{name}」を複製"),
("Swatch Name:", "スウォッチ名:"),
("Name with Color Value", "カラー値を名前にする"),
("Color Type:", "カラータイプ:"),
+1
View File
@@ -2293,6 +2293,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Last commit", "ბოლო კომიტი"),
("Layer Options", "შრის პარამეტრები"),
("Layer Options for \"{name}\"…", "შრის პარამეტრები: „{name}“…"),
("Duplicate Layer \"{name}\"", "შრის დუბლირება: „{name}“"),
("Leader:", "მიმთითებელი:"),
("Leading Model:", "სტრიქონთაშორისი ინტერვალის მოდელი:"),
("Left-aligned tab", "მარცხნივ სწორებული ტაბულაცია"),
+1
View File
@@ -328,6 +328,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Delete Column", "Usuń kolumnę"),
("Delete Condition", "Usuń warunek"),
("Delete Layer", "Usuń warstwę"),
("Duplicate Layer \"{name}\"", "Powiel warstwę „{name}”"),
("Delete Page", "Usuń stronę"),
("Delete Row", "Usuń wiersz"),
("Delete Swatch", "Usuń próbkę"),
+1
View File
@@ -1562,6 +1562,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Color Group Options…", "Opções do grupo de cores…"),
("Layer Options", "Opções de camada"),
("Layer Options for \"{name}\"…", "Opções da camada \"{name}\"…"),
("Duplicate Layer \"{name}\"", "Duplicar camada \"{name}\""),
("Swatch Name:", "Nome da amostra:"),
("Name with Color Value", "Nome com valor da cor"),
("Color Type:", "Tipo de cor:"),
+1
View File
@@ -327,6 +327,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Delete Column", "Удалить столбец"),
("Delete Condition", "Удалить условие"),
("Delete Layer", "Удалить слой"),
("Duplicate Layer \"{name}\"", "Дублировать слой «{name}»"),
("Delete Page", "Удалить страницу"),
("Delete Row", "Удалить строку"),
("Delete Swatch", "Удалить образец"),
+1
View File
@@ -2177,6 +2177,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Color Group Options…", "Alternativ för färggrupp…"),
("Layer Options", "Lageralternativ"),
("Layer Options for \"{name}\"…", "Lageralternativ för \"{name}\"…"),
("Duplicate Layer \"{name}\"", "Duplicera lagret ”{name}”"),
("Swatch Name:", "Färgrutenamn:"),
("Name with Color Value", "Namn med färgvärde"),
("Color Type:", "Färgtyp:"),
+4
View File
@@ -4834,6 +4834,10 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[
"“{name}”的图层选项…",
],
),
(
"Duplicate Layer \"{name}\"",
["Ebene „{name}“ duplizieren", "Dupliquer le calque « {name} »", "Duplicar capa «{name}»", "レイヤー「{name}」を複製", "复制图层“{name}”"],
),
("Swatch Name:", ["Farbfeldname:", "Nom de la nuance :", "Nombre de muestra:", "スウォッチ名:", "色板名称:"]),
(
"Name with Color Value",
+14
View File
@@ -267,6 +267,20 @@ fn links_panel_check_captions_are_localized_in_every_interface_language() {
}
/// Checks language-specific context precedence and fallback to the plain translation.
#[test]
fn duplicate_layer_menu_item_is_localized_in_every_interface_language() {
let key = "Duplicate Layer \"{name}\"";
for (code, name) in LANGUAGES {
let label = tr(code, key);
if code.is_empty() {
assert_eq!(label, key, "{name}");
} else {
assert_ne!(label, key, "{name} shows the Layers panel's Duplicate Layer item in English");
assert!(label.contains("{name}"), "{name}");
}
}
}
#[test]
fn context_captions_come_from_their_context_or_the_plain_translation() {
for (lang, table) in CONTEXT_TABLES {
+1
View File
@@ -1984,6 +1984,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Color Group Options…", "Параметри групи кольорів…"),
("Layer Options", "Параметри шару"),
("Layer Options for \"{name}\"…", "Параметри шару «{name}»…"),
("Duplicate Layer \"{name}\"", "Дублювати шар «{name}»"),
("Swatch Name:", "Назва зразка:"),
("Name with Color Value", "Назва за значенням кольору"),
("Color Type:", "Тип кольору:"),
+31 -1
View File
@@ -453,6 +453,11 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) {
ui.close();
}
ui.separator();
let duplicate = crate::i18n::tr(&language, "Duplicate Layer \"{name}\"").replace("{name}", &l.name);
if ui.button(crate::rtl::widget(ui, duplicate)).clicked() {
cmds.push(("layer.duplicate", json!({"id": l.id.0})));
ui.close();
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Delete Layer"))).clicked() {
cmds.push(("layer.delete", json!({"id": l.id.0})));
ui.close();
@@ -550,7 +555,14 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) {
if icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete Layer")).clicked() {
let _ = app.run("layer.delete", json!({"id": active.0}));
}
if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "Create New Layer")).clicked() {
// Create New Layer; a layer dropped on it is duplicated.
let dragging = matches!(egui::DragAndDrop::payload::<LayersDrag>(ui.ctx()).as_deref(), Some(LayersDrag::Layer(_)));
let plus = icons::button(ui, "plus", 20.0, dragging, crate::i18n::tr(&app.ui.language, "Create New Layer"));
if let Some(payload) = plus.dnd_release_payload::<LayersDrag>() {
if let LayersDrag::Layer(id) = *payload {
let _ = app.run("layer.duplicate", json!({"id": id.0}));
}
} else if plus.clicked() {
let _ = app.run("layer.new", json!({}));
}
});
@@ -670,6 +682,24 @@ mod tests {
assert_eq!(layers(h.state()), ["Art", "Layer 1"], "one undo step");
}
#[test]
fn a_layer_dropped_on_create_new_layer_is_duplicated() {
use egui_kittest::kittest::Queryable;
let mut app = app();
app.run("layer.new", json!({"name": "Art"})).unwrap();
app.run("frame.create", json!({"rect": [0, 0, 10, 10]})).unwrap();
app.run("selection.set", json!({"ids": []})).unwrap();
let mut h = panel(app);
let plus = h.get_by_label("Create New Layer").rect().center();
// Rows: Art, its rectangle, Layer 1. Drop Layer 1 on the button.
drag(&mut h, row(2, 0.5, false), plus);
assert_eq!(layers(h.state()), ["Art", "Layer 1 copy", "Layer 1"]);
drag(&mut h, row(0, 0.5, false), plus);
assert_eq!(layers(h.state()), ["Art copy", "Art", "Layer 1 copy", "Layer 1"]);
let d = &h.state().session.active().unwrap().doc;
assert_eq!(d.spreads[0].items.iter().filter(|i| i.layer == d.layers[0].id).count(), 1, "the copy holds the rectangle");
}
#[test]
fn dragging_objects_and_the_selection_square_moves_them_between_layers() {
let mut app = app();
+1 -1
View File
@@ -444,7 +444,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); the result keeps the frontmost object's look and content, so a photo intersected with shapes behind it shows through them (Subtract keeps the backmost); `object.pathfinder`.
- Pathfinder panel as InDesign's: Paths (Join Path `path.join`, Open/Close Path, Reverse Path), Pathfinder, Convert Shape, Convert Point (`path.convertPoints`: plain, corner, smooth, symmetrical).
- Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`.
- Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character).
- Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Duplicate, Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character).
- Floating panels: drag a panel's tab, or a group by its drag bar, off the dock (or choose Float Panel) into a movable floating window that resizes from every edge; Dock Panel (in its tab menu and ≡ menu) puts it back where it was; positions persist; `window.panel.float` / `window.panel.dock` (aliases `window.floatPanel` / `window.dockPanel`).
- Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). A swatch on a placed greyscale or bitmap image (IDML `<Image FillColor>`) colours it on screen and in PDF. New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing.
- View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough).