mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-25 10:46:50 -06:00
M2: resolve uppercase hyphenation roadmap conflict
Merge current main into PR #538 and retain both its uppercase-hyphenation progress entry and the shared printing entry. Validation: cargo xtask ci passed all six gates on the exact merge tree; 1,160 native tests passed, zero failed, three ignored, and all 15 WASM packages passed on macOS aarch64.
This commit is contained in:
Generated
+16
-7
@@ -796,6 +796,14 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5"
|
||||
dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -956,6 +964,7 @@ name = "designcraft-engine"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"caseless",
|
||||
"craft-print",
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
"designcraft-doc",
|
||||
@@ -1472,7 +1481,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2646,7 +2655,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"png",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2729,7 +2738,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3838,7 +3847,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4253,7 +4262,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4488,7 +4497,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5225,7 +5234,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -11,6 +11,7 @@ rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/designcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
|
||||
# internal
|
||||
designcraft-geom = { path = "crates/geom" }
|
||||
designcraft-color = { path = "crates/color" }
|
||||
|
||||
@@ -32,6 +32,26 @@ list (author, source, license) is in ASSETS.md.
|
||||
AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
- Portuguese hyphenation patterns, embedded through the hypher crate (feature "portuguese"):
|
||||
hyph-pt.tex 1.4 from hyph-utf8, https://github.com/hyphenation/tex-hyphen.
|
||||
Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida,
|
||||
2024 Leonardo Araujo and Aline Benevides. BSD 3-clause licence:
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted
|
||||
provided that the following conditions are met: * Redistributions of source code must retain the
|
||||
above copyright notice, this list of conditions and the following disclaimer. * Redistributions in
|
||||
binary form must reproduce the above copyright notice, this list of conditions and the following
|
||||
disclaimer in the documentation and/or other materials provided with the distribution. * Neither
|
||||
the name of the University of Campinas, of the University of Minho nor the names of its
|
||||
contributors may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||
CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
|
||||
EVENT SHALL PEDRO J. DE REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
- Japanese fonts (craft-fonts): builds made with craft-fonts (CRAFT_FONTS_DIR; all official
|
||||
releases) embed the fonts listed in
|
||||
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho,
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, full re-measure against InDesign 2026 21.6; restructured to the craftrules progress-docs standard) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026
|
||||
|
||||
DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON
|
||||
format + IDML), scriptable by agents (MCP), and available on the web.
|
||||
@@ -130,6 +130,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
Newest first.
|
||||
|
||||
- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change.
|
||||
- **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change.
|
||||
|
||||
- **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change.
|
||||
- **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI
|
||||
smoke-tested under QEMU).
|
||||
|
||||
@@ -18,7 +18,7 @@ designcraft-fonts = { workspace = true }
|
||||
log = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
miniz_oxide = "0.8"
|
||||
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "spanish"] }
|
||||
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "portuguese", "spanish"] }
|
||||
|
||||
[dev-dependencies]
|
||||
designcraft-fonts = { workspace = true, features = ["testing"] }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Hyphenation. English uses the bundled dictionary and patterns below. Other languages use the
|
||||
//! Liang patterns compiled into `hypher`, one cargo feature per language: Spanish is Javier Bezos's
|
||||
//! `hyph-es.tex` 5.0 from hyph-utf8, MIT (see NOTICE).
|
||||
//! `hyph-es.tex` 5.0 from hyph-utf8, MIT; Portuguese is `hyph-pt.tex` 1.4 from hyph-utf8 (Pedro J.
|
||||
//! de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides), BSD-3-Clause (see NOTICE).
|
||||
//!
|
||||
//! English lookup order for each word:
|
||||
//! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list
|
||||
@@ -391,6 +392,25 @@ mod tests {
|
||||
assert!(Lang::for_language("French").is_none(), "only languages built in");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portuguese_uses_the_hyph_pt_patterns() {
|
||||
let pt = Lang::for_language("Portuguese: Brazilian").unwrap();
|
||||
assert_eq!(pt, Lang::for_language("Portuguese").unwrap());
|
||||
assert_eq!(pt, Lang::for_language("pt_BR").unwrap());
|
||||
assert_ne!(pt, Lang::for_language("Spanish").unwrap());
|
||||
// hyph-pt's own minimum: three letters after the last break.
|
||||
let lim = Limits { before_last: 3, ..Limits::default() };
|
||||
let h = |w: &str| {
|
||||
let pts = hyphen_points_in(w, &lim, pt);
|
||||
w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::<String>()
|
||||
};
|
||||
assert_eq!(h("matemática"), "ma-te-má-tica");
|
||||
assert_eq!(h("orientação"), "ori-en-ta-ção");
|
||||
assert_eq!(h("Universidade"), "Uni-ver-si-dade");
|
||||
assert_eq!(h("experiência"), "ex-pe-ri-ên-cia");
|
||||
assert_eq!(h("pós-graduação"), "pós-gra-du-a-ção", "each part of a compound");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_fallback() {
|
||||
let c: Vec<char> = "happen".chars().collect();
|
||||
|
||||
@@ -1371,6 +1371,23 @@ fn spanish_text_gets_spanish_hyphenation() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portuguese_text_gets_portuguese_hyphenation() {
|
||||
let text = "matemática matemática matemática matemática matemática matemática";
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 76.0, 700.0), ParaAttrs::default());
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
let n = st.len();
|
||||
st.format_chars(0..n, |f| f.over.language = Some("Portuguese: Brazilian".into()));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let breaks: Vec<&str> = all_lines(&cs).into_iter().filter(|l| l.hyphenated).map(|l| text[l.range.clone()].trim_end()).collect();
|
||||
assert!(!breaks.is_empty(), "Portuguese text is hyphenated");
|
||||
// Portuguese syllables: ma-te-má-tica.
|
||||
for b in breaks {
|
||||
let tail = b.rsplit(' ').next().unwrap_or(b);
|
||||
assert!(["ma", "mate", "matemá"].contains(&tail), "{b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_aki_adds_space_without_scaling_outlines() {
|
||||
let (mut d, sid, _) = doc_with("AB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "DesignCraft engine: session, documents, history, selection, comma
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-print = { workspace = true }
|
||||
designcraft-images = { workspace = true }
|
||||
designcraft-geom = { workspace = true }
|
||||
designcraft-color = { workspace = true }
|
||||
|
||||
@@ -313,8 +313,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32))
|
||||
.ok_or_else(|| bad("file.place", "can't read that PDF page"))?,
|
||||
};
|
||||
// 72 ppi by default unless the file says otherwise; scale so it fits the page when huge.
|
||||
let (nw, nh) = eps_size.unwrap_or((pw as f64, ph as f64));
|
||||
// 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. PDF pages
|
||||
// and SVGs keep their exact size: rounding it to whole points would stretch them unevenly.
|
||||
let (nw, nh) = eps_size.or_else(|| designcraft_render::natural_size(&bytes, pdf_page as usize)).unwrap_or((pw as f64, ph as f64));
|
||||
// Image Import Options › Crop to: the frame shows that box of the PDF page.
|
||||
let crop_box = match str_param(p, "pdfCrop") {
|
||||
Some(k) if designcraft_render::is_pdf(&bytes) => {
|
||||
@@ -350,14 +351,14 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = if let Some(fid) = target_frame {
|
||||
let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;
|
||||
let r = it.inner_bounds();
|
||||
// Fill frame proportionally.
|
||||
// Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops.
|
||||
let k = (r.width() / nw).max(r.height() / nh);
|
||||
let (gw, gh) = (nw * k, nh * k);
|
||||
it.content = Content::Graphic(Graphic {
|
||||
asset: aid,
|
||||
size: (nw, nh),
|
||||
xf: Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k),
|
||||
auto_fit: designcraft_doc::Fitting::FillProportionally,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
});
|
||||
@@ -440,6 +441,7 @@ pub(crate) fn read_source(p: &Value) -> Result<(Vec<u8>, String, Option<String>)
|
||||
fn place_load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (bytes, name, link) = read_source(p)?;
|
||||
let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?;
|
||||
let natural = designcraft_render::natural_size(&bytes, 0).unwrap_or((pw as f64, ph as f64));
|
||||
let aid = s.edit(|d, _| {
|
||||
let aid = AssetId(d.alloc());
|
||||
let mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
@@ -449,7 +451,7 @@ fn place_load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
);
|
||||
Ok(aid)
|
||||
})?;
|
||||
s.loaded = Some((aid, (pw as f64, ph as f64)));
|
||||
s.loaded = Some((aid, natural));
|
||||
s.set_tool("placeGun");
|
||||
Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph}))
|
||||
}
|
||||
@@ -471,7 +473,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
asset: aid,
|
||||
size: (nw, nh),
|
||||
xf: Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k),
|
||||
auto_fit: designcraft_doc::Fitting::FillProportionally,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
});
|
||||
@@ -689,6 +691,24 @@ mod pdf_crop_tests {
|
||||
assert!((trim.width() - 612.0).abs() < 1e-3 && (trim.height() - 792.0).abs() < 1e-3, "{trim:?}");
|
||||
assert!(t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_pdf_keeps_its_exact_page_size() {
|
||||
// A 100.4 x 50.4 pt page placed 100.4 pt wide is 50.4 pt tall, not stretched to whole points.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100.4, "height": 50.4, "pages": 1})).unwrap();
|
||||
let b64 = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string();
|
||||
let mut t = Session::new();
|
||||
t.execute("file.new", &json!({})).unwrap();
|
||||
let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "width": 100.4, "x": 0, "y": 0})).unwrap();
|
||||
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
|
||||
let d = &t.doc().unwrap().doc;
|
||||
let it = d.item(id).unwrap();
|
||||
let b = it.bounds();
|
||||
assert!((b.width() - 100.4).abs() < 1e-3 && (b.height() - 50.4).abs() < 1e-3, "{b:?}");
|
||||
let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("not a graphic") };
|
||||
assert!((g.size.0 - 100.4).abs() < 1e-3 && (g.size.1 - 50.4).abs() < 1e-3, "{:?}", g.size);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -2463,8 +2463,8 @@ mod fitting_tests {
|
||||
s.execute("object.fittingOptions", &json!({"ids": [f], "fitting": "fitProportionally", "align": 0})).unwrap();
|
||||
let c = content(&s);
|
||||
assert!((c.x0 - 100.0).abs() < 1e-6 && (c.y0 - 100.0).abs() < 1e-6 && (c.width() - 200.0).abs() < 1e-6, "{c:?}");
|
||||
// Crop 10 pt on every side: the content spreads past the frame.
|
||||
s.execute("object.fittingOptions", &json!({"ids": [f], "crop": 10, "align": 4})).unwrap();
|
||||
// Crop 10 pt on every side and fit again: the content spreads past the frame.
|
||||
s.execute("object.fittingOptions", &json!({"ids": [f], "fitting": "fitProportionally", "crop": 10, "align": 4})).unwrap();
|
||||
let c = content(&s);
|
||||
assert!((c.width() - 220.0).abs() < 1e-6, "{c:?}");
|
||||
// Auto-fit: resizing the frame refits the content.
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
//! File › Print: the pages go to a printer through the system print spooler (`lpr`), as PDF.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::io::Write;
|
||||
//! File › Print: the pages go to a printer through the system print spooler (craft-print), as PDF.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -20,91 +17,60 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print…",
|
||||
["File"],
|
||||
Some("Cmd+P"),
|
||||
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (only return the spool command)} → {printer, copies, pages, command}",
|
||||
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing)} → {printer, copies, pages, command, bytes}",
|
||||
has_doc,
|
||||
print
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn printers() -> Value {
|
||||
let run = |args: &[&str]| {
|
||||
std::process::Command::new("lpstat").args(args).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default()
|
||||
};
|
||||
let names: Vec<String> = run(&["-e"]).lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect();
|
||||
// "system default destination: NAME"
|
||||
let default = run(&["-d"]).rsplit(':').next().map(str::trim).filter(|s| !s.is_empty() && !s.contains("no system default")).map(str::to_string);
|
||||
let printers = craft_print::printers();
|
||||
let default = printers.iter().find(|p| p.default).map(|p| p.name.clone());
|
||||
let names: Vec<_> = printers.into_iter().map(|p| p.name).collect();
|
||||
json!({"printers": names, "default": default})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn printers() -> Value {
|
||||
json!({"printers": [], "default": null})
|
||||
}
|
||||
|
||||
fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let opts = super::export::options(p, d.page_count())?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// Even a dry run cannot construct a native spool command in the browser.
|
||||
let _ = opts;
|
||||
super::export::options(p, s.doc()?.doc.page_count())?;
|
||||
Err(bad("file.print", "printing isn't available on the web: export a PDF and print it"))
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999);
|
||||
let printer = str_param(p, "printer").map(str::to_string);
|
||||
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
|
||||
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut file = secure_print_file()?;
|
||||
let file_path = file.path().to_path_buf();
|
||||
let mut cmd: Vec<String> = vec!["lpr".into()];
|
||||
if let Some(pr) = &printer {
|
||||
cmd.extend(["-P".into(), pr.clone()]);
|
||||
}
|
||||
if copies > 1 {
|
||||
cmd.extend(["-#".into(), copies.to_string()]);
|
||||
}
|
||||
cmd.extend(["-T".into(), d.title.clone(), file_path.to_string_lossy().to_string()]);
|
||||
let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
|
||||
let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()});
|
||||
if dry {
|
||||
return Ok(out);
|
||||
}
|
||||
file.as_file_mut().write_all(&pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
|
||||
file.as_file_mut().flush().map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
|
||||
let st = std::process::Command::new(&cmd[0]).args(&cmd[1..]).output().map_err(|e| bad("file.print", format!("lpr: {e}")))?;
|
||||
if !st.status.success() {
|
||||
return Err(bad("file.print", String::from_utf8_lossy(&st.stderr).trim().to_string()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
print_with(s, p, craft_print::submit)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn secure_print_file() -> Result<tempfile::NamedTempFile> {
|
||||
let file = tempfile::Builder::new()
|
||||
.prefix("designcraft-print-")
|
||||
.suffix(".pdf")
|
||||
.tempfile()
|
||||
.map_err(|e| EngineError::Other(format!("temporary print file: {e}")))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.as_file()
|
||||
.set_permissions(std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
|
||||
fn print_with(
|
||||
s: &mut Session,
|
||||
p: &Value,
|
||||
submit: impl FnOnce(&[u8], &craft_print::Job) -> std::result::Result<String, craft_print::Error>,
|
||||
) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let opts = super::export::options(p, d.page_count())?;
|
||||
let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999) as u32;
|
||||
let job = craft_print::Job {
|
||||
printer: str_param(p, "printer").map(str::to_string),
|
||||
copies,
|
||||
scaling: craft_print::Scaling::ShrinkToFit,
|
||||
title: d.title.clone(),
|
||||
..craft_print::Job::default()
|
||||
};
|
||||
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
|
||||
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let command = craft_print::command_preview(&job).map_err(|e| bad("file.print", e.to_string()))?;
|
||||
let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
|
||||
let out = json!({"printer": job.printer, "copies": copies, "pages": pages, "command": command, "bytes": pdf.len()});
|
||||
if !dry {
|
||||
submit(&pdf, &job).map_err(|e| bad("file.print", e.to_string()))?;
|
||||
}
|
||||
Ok(file)
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn print_builds_the_spool_command() {
|
||||
@@ -112,37 +78,45 @@ mod tests {
|
||||
s.execute("file.new", &json!({"pages": 4})).unwrap();
|
||||
let r = s.execute("file.print", &json!({"printer": "Office", "copies": 2, "pages": "2-3", "dryRun": true})).unwrap();
|
||||
assert_eq!(r["pages"], 2);
|
||||
let cmd: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
|
||||
assert_eq!(&cmd[..5], ["lpr", "-P", "Office", "-#", "2"]);
|
||||
assert!(cmd.last().unwrap().ends_with(".pdf"));
|
||||
assert!(!std::path::Path::new(cmd.last().unwrap()).exists());
|
||||
let command: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
if craft_print::backend() == Some(craft_print::Backend::Cups) {
|
||||
assert_eq!(&command[..5], ["lp", "-d", "Office", "-n", "2"]);
|
||||
assert!(command.contains(&"print-scaling=auto-fit"));
|
||||
assert!(!command.iter().any(|a| a.starts_with("sides=")));
|
||||
} else {
|
||||
assert_eq!(command[0], "powershell");
|
||||
}
|
||||
assert!(r["bytes"].as_u64().unwrap() > 500);
|
||||
assert!(s.execute("file.print", &json!({"pages": "9", "dryRun": true})).is_err());
|
||||
assert!(s.execute("file.printers", &json!({})).unwrap()["printers"].is_array());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn print_uses_distinct_ephemeral_files() {
|
||||
let first = super::secure_print_file().unwrap();
|
||||
let second = super::secure_print_file().unwrap();
|
||||
let first_path = first.path().to_path_buf();
|
||||
let second_path = second.path().to_path_buf();
|
||||
assert_ne!(first_path, second_path);
|
||||
drop((first, second));
|
||||
assert!(!first_path.exists());
|
||||
assert!(!second_path.exists());
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_arch = "wasm32"), unix))]
|
||||
#[test]
|
||||
fn print_file_is_private_and_removed_on_drop() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let file = super::secure_print_file().unwrap();
|
||||
let path = file.path().to_path_buf();
|
||||
assert_eq!(file.as_file().metadata().unwrap().permissions().mode() & 0o777, 0o600);
|
||||
drop(file);
|
||||
assert!(!path.exists());
|
||||
fn print_submits_the_selected_export_and_reports_refusal() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 4})).unwrap();
|
||||
let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true});
|
||||
let expected = {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let options = super::super::export::options(¶ms, d.page_count()).unwrap();
|
||||
designcraft_pdf::export_pdf(d, &s.cache, &options).unwrap()
|
||||
};
|
||||
let r = print_with(&mut s, ¶ms, |pdf, job| {
|
||||
// The production export options and full bytes reach the shared spooler boundary.
|
||||
assert_eq!(pdf, expected);
|
||||
assert!(pdf.starts_with(b"%PDF-"));
|
||||
assert_eq!(job.printer.as_deref(), Some("Office (east)"));
|
||||
assert_eq!(job.copies, 2);
|
||||
assert_eq!(job.duplex, None);
|
||||
assert_eq!(job.scaling, craft_print::Scaling::ShrinkToFit);
|
||||
assert!(!job.title.is_empty());
|
||||
Ok("request id is Office-1".into())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(r["pages"], 2);
|
||||
let refusal = print_with(&mut s, ¶ms, |_, _| Err(craft_print::Error::Spool("printer offline".into()))).unwrap_err();
|
||||
assert!(refusal.to_string().contains("printer offline"));
|
||||
print_with(&mut s, &json!({"dryRun": true}), |_, _| panic!("dry runs must not spool")).unwrap();
|
||||
assert!(print_with(&mut s, &json!({"pages": "9"}), |_, _| panic!("invalid ranges must not spool")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,13 +716,18 @@ pub fn parse_color(v: &Value) -> Option<designcraft_color::Color> {
|
||||
use designcraft_color::Color;
|
||||
match v {
|
||||
Value::String(s) => Color::from_hex(s),
|
||||
// One scale per colour, not per component: {c: 0.7, y: 12.2} is C 0.7 %, Y 12.2 %. Only a colour whose
|
||||
// components are all within 0..1 is read as fractions (the older form).
|
||||
Value::Array(a) if a.len() >= 3 => {
|
||||
let f = |i: usize| a[i].as_f64().map(|x| if x > 1.0 { x / 255.0 } else { x } as f32);
|
||||
Some(Color::rgb(f(0)?, f(1)?, f(2)?))
|
||||
let v = [a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?];
|
||||
let scale = if v.iter().any(|&x| x > 1.0) { 255.0 } else { 1.0 };
|
||||
Some(Color::rgb((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32))
|
||||
}
|
||||
Value::Object(o) => {
|
||||
let g = |k: &str| o.get(k).and_then(Value::as_f64).map(|x| if x > 1.0 { x / 100.0 } else { x } as f32);
|
||||
Some(Color::cmyk(g("c")?, g("m")?, g("y")?, g("k")?))
|
||||
let g = |k: &str| o.get(k).and_then(Value::as_f64);
|
||||
let v = [g("c")?, g("m")?, g("y")?, g("k")?];
|
||||
let scale = if v.iter().any(|&x| x > 1.0) { 100.0 } else { 1.0 };
|
||||
Some(Color::cmyk((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32, (v[3] / scale) as f32))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -1040,6 +1045,20 @@ fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) {
|
||||
mod color_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn percent_components_at_most_one_stay_percent() {
|
||||
use designcraft_color::Color;
|
||||
// {"c": 0.7, ..., "y": 12.2} is a pale cream (C 0.7 %, Y 12.2 %), not C 70 %.
|
||||
let Some(Color::Cmyk { c, m, y, k }) = parse_color(&json!({"c": 0.7, "m": 0, "y": 12.2, "k": 0})) else { panic!("not CMYK") };
|
||||
assert!((c - 0.007).abs() < 1e-6 && m == 0.0 && (y - 0.122).abs() < 1e-6 && k == 0.0, "got {c} {m} {y} {k}");
|
||||
// Fractions (every component within 0..1) keep working.
|
||||
let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") };
|
||||
assert!((c - 0.5).abs() < 1e-6 && (k - 1.0).abs() < 1e-6);
|
||||
// RGB arrays follow the same rule: [1, 128, 255] is 0..255.
|
||||
let Some(Color::Rgb { r, g, b }) = parse_color(&json!([1, 128, 255])) else { panic!("not RGB") };
|
||||
assert!((r - 1.0 / 255.0).abs() < 1e-6 && (g - 128.0 / 255.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_groups_rename_every_use() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -284,7 +284,15 @@ fn place_gun_click_drag_and_into_frame() {
|
||||
let f = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("place.load", &json!({"base64": b64})).unwrap();
|
||||
s.execute("place.drop", &json!({"frame": f})).unwrap();
|
||||
assert!(matches!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().content, designcraft_doc::Content::Graphic(_)));
|
||||
let graphic_xf = |s: &Session| match &s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().content {
|
||||
designcraft_doc::Content::Graphic(g) => g.xf,
|
||||
_ => panic!("not a graphic"),
|
||||
};
|
||||
let placed = graphic_xf(&s);
|
||||
// Dragging the top edge down crops the top only: the image stays where it is.
|
||||
s.execute("selection.set", &json!({"ids": [f]})).unwrap();
|
||||
s.execute("transform.resize", &json!({"from": [300, 300, 400, 400], "to": [300, 340, 400, 400]})).unwrap();
|
||||
assert_eq!(graphic_xf(&s), placed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1022,6 +1022,15 @@ pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
designcraft_images::pixel_size(bytes)
|
||||
}
|
||||
|
||||
/// Natural size of a placed graphic, unrounded: a PDF page (crop box) and an SVG in points, a raster
|
||||
/// in pixels. Placed graphics are drawn at this size; `image_size` rounds it to whole pixels for display.
|
||||
pub fn natural_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> {
|
||||
if is_pdf(bytes) {
|
||||
return pdf_page_size(bytes, page).map(|(w, h)| (w.max(1e-3), h.max(1e-3)));
|
||||
}
|
||||
designcraft_images::natural_size(bytes)
|
||||
}
|
||||
|
||||
/// MIME type guess for encoded image bytes.
|
||||
/// Is this a PDF file (placed PDFs are graphics)?
|
||||
pub fn is_pdf(bytes: &[u8]) -> bool {
|
||||
|
||||
@@ -251,8 +251,9 @@ struct FontMenuState {
|
||||
/// The Font menu, inside any font field's popup or combo box: a search field (it has the keyboard
|
||||
/// while the menu is open, and starts empty each time it opens), favourites (★, Show Favorites
|
||||
/// Only), and each family's name with a sample in that family, the script groups apart. ↑/↓ move
|
||||
/// through the matches and Return picks one. Returns the family picked and closes the menu; the
|
||||
/// search, Favorites and the stars keep it open (the popup closes on a click outside only).
|
||||
/// through the matches, Return picks one and Escape closes the menu; none of them reach a dialog
|
||||
/// under the menu. Returns the family picked and closes the menu; the search, Favorites and the
|
||||
/// stars keep it open (the popup closes on a click outside only).
|
||||
pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraft_fonts::FamilyInfo], current: &str, width: f32) -> Option<String> {
|
||||
let (fonts, scope) = (fonts(app), font_scope(app));
|
||||
let english = app.session.prefs.show_font_names_in_english;
|
||||
@@ -293,7 +294,12 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraf
|
||||
if search.changed() {
|
||||
st.highlight = if q.is_empty() { None } else { Some(0) };
|
||||
}
|
||||
let (down, up, enter) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp), i.key_pressed(egui::Key::Enter)));
|
||||
// The menu owns these keys while it is open: a dialog under it must not take Return as OK or
|
||||
// Escape as Cancel.
|
||||
let (down, up, enter, escape) = ui.input_mut(|i| {
|
||||
let mut key = |k| i.consume_key(egui::Modifiers::NONE, k);
|
||||
(key(egui::Key::ArrowDown), key(egui::Key::ArrowUp), key(egui::Key::Enter), key(egui::Key::Escape))
|
||||
});
|
||||
let last = shown.len().checked_sub(1);
|
||||
st.highlight = match (st.highlight, last) {
|
||||
(_, None) => None,
|
||||
@@ -374,7 +380,7 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraf
|
||||
if favs_changed {
|
||||
let _ = app.run("prefs.set", json!({ "favoriteFonts": favs }));
|
||||
}
|
||||
if pick.is_some() {
|
||||
if pick.is_some() || escape {
|
||||
ui.close();
|
||||
}
|
||||
pick
|
||||
@@ -404,6 +410,12 @@ pub fn font_combo(
|
||||
/// [`font_menu_body`] in a popup under `field` (a font field the caller draws). Returns the family
|
||||
/// picked.
|
||||
pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option<String> {
|
||||
// Screen readers (and tests) see the field as the combo box it looks like.
|
||||
field.widget_info(|| {
|
||||
let mut info = egui::WidgetInfo::new(egui::WidgetType::ComboBox);
|
||||
info.current_text_value = Some(if current.is_empty() { "—".to_string() } else { font_label(app, menu, current) });
|
||||
info
|
||||
});
|
||||
egui::Popup::menu(field)
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.show(|ui| font_menu_body(app, ui, menu, current, field.rect.width()))
|
||||
@@ -506,9 +518,22 @@ mod font_menu_ui_tests {
|
||||
/// The Type tool's selection over a new text frame's text, with the Control bar on. Returns the
|
||||
/// story.
|
||||
fn typing() -> (Harness<'static, Window>, u64) {
|
||||
typing_in("Advanced")
|
||||
}
|
||||
|
||||
/// [`typing`] in the workspace `name`.
|
||||
fn typing_in(name: &str) -> (Harness<'static, Window>, u64) {
|
||||
typing_on(name, vec2(1440.0, 900.0), false)
|
||||
}
|
||||
|
||||
/// [`typing_in`] a window of `size`, with the macOS window chrome (the native menu bar and the
|
||||
/// title bar inside the window) when `mac`.
|
||||
fn typing_on(name: &str, size: egui::Vec2, mac: bool) -> (Harness<'static, Window>, u64) {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui.control_bar = true;
|
||||
let mut h = test_window::open(app, vec2(1440.0, 900.0));
|
||||
app.run("window.workspace", json!({ "name": name })).unwrap();
|
||||
app.native_menu = mac;
|
||||
app.integrated_titlebar = mac;
|
||||
let mut h = test_window::open(app, size);
|
||||
let app = &mut h.state_mut().app;
|
||||
let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello", "caret": true})).unwrap();
|
||||
app.run("tool.select", json!({"tool": "type"})).unwrap();
|
||||
@@ -523,7 +548,7 @@ mod font_menu_ui_tests {
|
||||
|
||||
/// The Control bar's font field, showing `family`.
|
||||
fn field(h: &Harness<'static, Window>, family: &str) -> egui::Rect {
|
||||
h.get_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(family)).rect()
|
||||
fields(h, family)[0]
|
||||
}
|
||||
|
||||
/// The text in the focused search field.
|
||||
@@ -582,4 +607,94 @@ mod font_menu_ui_tests {
|
||||
assert_eq!(story_text(&mut h, story), before);
|
||||
assert!(h.state().app.session.active().unwrap().selection.text.is_some(), "the text is still selected");
|
||||
}
|
||||
|
||||
/// The font fields showing `family`, left to right and top to bottom.
|
||||
fn fields(h: &Harness<'static, Window>, family: &str) -> Vec<egui::Rect> {
|
||||
let mut r: Vec<egui::Rect> =
|
||||
h.query_all_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(family)).map(|n| n.rect()).collect();
|
||||
r.sort_by(|a, b| a.min.x.total_cmp(&b.min.x).then(a.min.y.total_cmp(&b.min.y)));
|
||||
r
|
||||
}
|
||||
|
||||
/// Opens the font menu of the field at `at`, clicks its search field, types, checks the menu
|
||||
/// stays open and lists only the matches, then picks the first with ↓ and Return.
|
||||
fn search_in_menu(h: &mut Harness<'static, Window>, at: egui::Rect, what: &str) {
|
||||
click_at(h, at.center());
|
||||
let field = h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_focused()).map(|n| n.rect());
|
||||
let field = field.unwrap_or_else(|| panic!("{what}: the menu opens with the search field focused"));
|
||||
click_at(h, field.center());
|
||||
assert!(h.ctx.text_edit_focused(), "{what}: a click on the search field keeps the menu open");
|
||||
h.event(egui::Event::Text("SOURCE SA".into()));
|
||||
h.run_steps(4);
|
||||
assert_eq!(search(h), "SOURCE SA", "{what}: the typing went to the search");
|
||||
assert!(h.query_by_label("Source Sans 3").is_some(), "{what}: a match is listed");
|
||||
assert!(h.query_by_label(designcraft_fonts::DEFAULT_FAMILY).is_none(), "{what}: families that don't match aren't");
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(4);
|
||||
assert!(!h.ctx.text_edit_focused(), "{what}: the menu closed");
|
||||
}
|
||||
|
||||
/// Every font field over selected text (Control bar, Contextual Task Bar, Properties ›
|
||||
/// Character, Character panel) searches, in the workspaces that show them and in a small
|
||||
/// window with the macOS window chrome.
|
||||
#[test]
|
||||
fn every_font_field_searches_in_every_workspace() {
|
||||
for (name, size, mac, panel) in [
|
||||
("Advanced", vec2(1440.0, 900.0), false, None),
|
||||
("Printing and Proofing", vec2(1440.0, 900.0), false, None),
|
||||
("Essentials", vec2(1440.0, 900.0), false, Some("character")),
|
||||
("Printing and Proofing", vec2(900.0, 560.0), true, None),
|
||||
] {
|
||||
let (mut h, story) = typing_on(name, size, mac);
|
||||
if panel.is_some() {
|
||||
h.state_mut().app.ui.open_panel = panel.map(str::to_string);
|
||||
h.run_steps(4);
|
||||
}
|
||||
let name = &format!("{name} {size:?} {panel:?}");
|
||||
let before = story_text(&mut h, story);
|
||||
let count = fields(&h, designcraft_fonts::DEFAULT_FAMILY).len();
|
||||
assert_eq!(count, 3, "{name}: the font fields are shown");
|
||||
for k in 0..count {
|
||||
h.state_mut().app.run("type.char", json!({"attrs": {"fontFamily": designcraft_fonts::DEFAULT_FAMILY}})).unwrap();
|
||||
h.run_steps(4);
|
||||
let at = fields(&h, designcraft_fonts::DEFAULT_FAMILY)[k];
|
||||
search_in_menu(&mut h, at, &format!("{name}, field {k}"));
|
||||
assert_eq!(family(&mut h), "Source Sans 3", "{name}, field {k}");
|
||||
assert_eq!(story_text(&mut h, story), before, "{name}, field {k}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The font fields away from the text: the Glyphs panel, Paragraph Style Options › Basic
|
||||
/// Character Formats and Find/Replace Font's Replace With.
|
||||
#[test]
|
||||
fn font_fields_in_panels_and_dialogs_search() {
|
||||
let paragraph = json!({"name": designcraft_doc::BASIC_PARAGRAPH, "section": "chars"});
|
||||
for (what, panel, dialog, shown) in [
|
||||
("Glyphs", Some("glyphs"), None, designcraft_fonts::DEFAULT_FAMILY),
|
||||
("Paragraph Style Options", None, Some(("paragraphStyleOptions", paragraph)), designcraft_fonts::DEFAULT_FAMILY),
|
||||
("Find/Replace Font", None, Some(("findFont", json!({}))), "—"),
|
||||
] {
|
||||
let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
let mut h = test_window::open(app, vec2(1440.0, 900.0));
|
||||
let dialog_id = dialog.as_ref().map(|(id, _)| id.to_string());
|
||||
h.state_mut().app.ui.open_panel = panel.map(str::to_string);
|
||||
h.state_mut().app.ui.dialog = dialog.map(|(id, fields)| crate::dialogs::Dialog::new(id, fields));
|
||||
h.run_steps(4);
|
||||
let at = *fields(&h, shown).first().unwrap_or_else(|| panic!("{what}: the font field is shown"));
|
||||
search_in_menu(&mut h, at, what);
|
||||
assert_eq!(h.state().app.ui.dialog.as_ref().map(|d| d.id.clone()), dialog_id, "{what}: Return in the menu leaves the dialog open");
|
||||
assert!(!fields(&h, "Source Sans 3").is_empty(), "{what}: the field shows the family picked");
|
||||
// Escape closes the menu only.
|
||||
let at = fields(&h, "Source Sans 3")[0];
|
||||
click_at(&mut h, at.center());
|
||||
assert!(h.ctx.text_edit_focused(), "{what}: the menu opened");
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(4);
|
||||
assert!(!h.ctx.text_edit_focused(), "{what}: Escape closed the menu");
|
||||
assert_eq!(h.state().app.ui.dialog.as_ref().map(|d| d.id.clone()), dialog_id, "{what}: Escape in the menu leaves the dialog open");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: every format InDesign 2026 declares, measured against the code) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The
|
||||
InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`,
|
||||
@@ -46,7 +46,7 @@ score below).
|
||||
| EPS export | `.eps` | ✓ | ✗ | | <1% |
|
||||
| Text Only / RTF / Tagged Text | `.txt`, `.rtf` | ✓ | ✓ (`file.exportText` txt, rtf, tagged) | | 1% |
|
||||
| XML | `.xml` | ✓ | ✓ export and import, DTD validate | depth unmeasured | 1% |
|
||||
| Print / PostScript | | ✓ printers, PPDs, separations | ~ PDF to `lpr` (no Windows, no PostScript) | | 3% |
|
||||
| Print / PostScript | | ✓ printers, PPDs, separations | ~ PDF to CUPS/Windows (craft-print; no PostScript) | | 3% |
|
||||
| Package | folder | ✓ fonts, links, IDML, PDF, report | ✓ (#340: IDML inside links to absolute paths) | | 2% |
|
||||
|
||||
## Placed and imported content
|
||||
@@ -97,7 +97,7 @@ for real work** (estimated). INDD alone moves this dimension by up to 30 points.
|
||||
| INDD import via a clean-room converter (owner decision, #114) | 20–60 |
|
||||
| IDML real-file corpus, metric, fixes (#193, #338, #339, #340) | 60–100 |
|
||||
| PDF/X-1a and X-3 (transparency flattener), PDF presets and `.joboptions`, external validator | 40–70 |
|
||||
| Print path (Windows, PostScript level 3 output or system print APIs) | 25–45 |
|
||||
| Print fidelity, PostScript level 3 output and print presets | 25–45 |
|
||||
| ICML story import, IDMS snippets, INDT templates (once INDD reads) | 15–25 |
|
||||
| HTML / fixed-layout EPUB with live text | 20–35 |
|
||||
| JPEG export dialog, SVG export, EPS export | 10–20 |
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
# Where DesignCraft falls short of InDesign
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, first version: gaps from the full re-measure and the issue tracker) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an
|
||||
InDesign professional from doing real work. This is the work list: agents pick from the top.
|
||||
@@ -117,8 +117,10 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
- **Estimate:** 40–70 h. **Doc:** [file-format-parity.md](file-format-parity.md)
|
||||
|
||||
### 10. Printing (H, F)
|
||||
- **Missing:** a real print path: `file.print` sends PDF to `lpr`, so Windows can't print; no
|
||||
PostScript, printer PPDs, separations to device, print presets, or trapping.
|
||||
- **Missing:** PostScript, a printer-driver options UI, separations to device, print presets, or trapping.
|
||||
- **Have:** `file.print` exports selected pages/spreads with marks and bleed, then submits through
|
||||
craft-print: CUPS on macOS/Linux and the Windows PDF raster spooler. Physical printer fidelity
|
||||
remains a hardware check.
|
||||
- **Estimate:** 25–45 h. **Doc:** [hardware-parity.md](hardware-parity.md)
|
||||
|
||||
### 11. Paragraph and character style editing reliability (F)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware and platform parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
What InDesign does with hardware, per platform, against DesignCraft. Layout is not a
|
||||
GPU-hungry workload, so this dimension carries little weight, but a start that fails on a
|
||||
@@ -19,7 +19,7 @@ graphics driver loses the user entirely. Overall numbers: [target-app-parity.md]
|
||||
| HiDPI / Retina | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| UI scaling | ✓ | ✓ (menus and dialogs usable at larger scales, #281) | ✓ | ✓ | ✓ |
|
||||
| Multi-monitor, floating document windows | ✓ | ~ one window; New Window for the same document | ~ | ~ | ✗ |
|
||||
| Printing | system print with PPDs, PostScript, separations | PDF to `lpr` | ✗ (no `lpr`) | PDF to `lpr` (CUPS) | browser download |
|
||||
| Printing | system print with PPDs, PostScript, separations | PDF to CUPS (`craft-print`) | Windows PDF raster spooler (`craft-print`) | PDF to CUPS (`craft-print`) | browser download |
|
||||
| Colour-managed display (monitor profile) | ✓ | ✗ (working CMYK profile simulated; monitor profile not applied) | ✗ | ✗ | ✗ |
|
||||
| Touch / pen | Touch workspace (Windows), gestures | trackpad pinch/scroll | ~ | ~ | ~ |
|
||||
| Multi-core composition | single-threaded composer per story (documented by Adobe as mostly single-threaded) | parallel story composition (rayon), 6.7× on the sample | ✓ | ✓ | single-threaded |
|
||||
@@ -43,7 +43,7 @@ the developers don't use daily.
|
||||
| Work | Opus 5.5 h |
|
||||
|---|---|
|
||||
| Start-up robustness on every GPU: confirm #297 with reporters, Intel Metal fix, CPU-only present fallback | 15–30 |
|
||||
| Printing on Windows (system print API) and print presets | 15–30 (counted in file-format-parity.md) |
|
||||
| Printing fidelity, PostScript and print presets | 15–30 (counted in file-format-parity.md) |
|
||||
| Monitor profile in display colour management | 5–10 |
|
||||
| Windows and Linux runtime smoke tests in CI | 20–40 |
|
||||
| Multi-window document views | 15–30 |
|
||||
@@ -54,3 +54,7 @@ the developers don't use daily.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | Created from the code, InDesign 2026's GPU Performance features and the issue tracker |
|
||||
|
||||
Windows submission now uses shared craft-print. It selects matching or default driver paper,
|
||||
shrinks oversized sheets to the printable area without enlarging smaller ones, and preserves
|
||||
the queue's duplex default; printer-driver options UI and physical output validation remain open.
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# DesignCraft roadmap (detail)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, milestones re-assessed; beta plan from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, milestones re-assessed; beta plan from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
Forward-looking plan. The one-page summary is [ROADMAP.md](../ROADMAP.md); numbers are in
|
||||
[target-app-parity.md](target-app-parity.md); the work list is [gaps.md](gaps.md). Hours are
|
||||
@@ -88,7 +88,7 @@ that is ~28 points and **420–750 h** (~120–200 wall-clock hours with four to
|
||||
| Start-up robustness, fonts | 5, 6 | 25–50 |
|
||||
| CI, Windows/Linux runtime tests | 7, 14 | 35–70 |
|
||||
| IME | 8 | 10–20 |
|
||||
| PDF/X-1a/X-3, presets, print on Windows | 9, 10 | 65–110 |
|
||||
| PDF/X-1a/X-3, presets, physical print validation | 9, 10 | 65–110 |
|
||||
| Styles, tables, missing menu items (the most-used half) | 11, 12, 16 | 60–100 |
|
||||
| Long documents (Books) | 15 | 30–50 |
|
||||
| **Total** | | **420–750** |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# DesignCraft parity with Adobe InDesign
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, full re-measure against InDesign 2026 21.6; replaces the 2026-10-04 estimate in ROADMAP.md) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
|
||||
|
||||
The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md)
|
||||
summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature
|
||||
@@ -66,7 +66,7 @@ they receive, setting type exactly, and basic editing that never fails.
|
||||
| Performance | 5% | 65% | 40–80 | 300k characters compose in ~38 ms warm; typing repaints in < 1 ms (damage regions); no incremental composition; nothing benchmarked against InDesign; no large real documents | [gaps.md](gaps.md) |
|
||||
| Localization | 3% | 45% | 120–180 | 9 translations beyond English; Japanese 95%, most others 83–85%, pt-BR 61%; Hindi, Indonesian, Korean, Vietnamese none | [localization-parity.md](localization-parity.md) |
|
||||
| Platforms | 3% | 55% | 40–80 | macOS is where it's tested; Windows and Linux users hit basic failures; web and FreeBSD builds exist | [hardware-parity.md](hardware-parity.md) |
|
||||
| Hardware | 2% | 45% | 20–40 | Canvas rasterizes on the CPU (vello_cpu, multithreaded); GPU only presents; printing is `lpr` (no Windows printing, no PostScript) | [hardware-parity.md](hardware-parity.md) |
|
||||
| Hardware | 2% | 45% | 20–40 | Canvas rasterizes on the CPU (vello_cpu, multithreaded); GPU only presents; printing uses shared CUPS/Windows PDF spoolers (no PostScript; physical output needs validation) | [hardware-parity.md](hardware-parity.md) |
|
||||
| Ecosystem (scripting, plug-ins) | 2% | 15% | 150–250 | Own command/script/MCP API is strong; no ExtendScript/UXP DOM, so InDesign scripts don't run; no plug-ins | [gaps.md](gaps.md) |
|
||||
| AI features | 0% (not weighted) | 15% | 60–120 | Agent control through MCP goes beyond InDesign; no generative features (InDesign 2026: Generative Expand, Text to Image, Auto Style) | [gaps.md](gaps.md) |
|
||||
| **Weighted sum** | 100% | **47.2% → ~47%** (range 42–52%) | **1,150–1,950** | | |
|
||||
@@ -117,7 +117,7 @@ twice.
|
||||
| Tables | 5% | 55% | anchored objects in cells, Table Options submenus |
|
||||
| Layers | 3% | 75% | |
|
||||
| Effects and transparency | 3% | 50% | fidelity unmeasured |
|
||||
| Output: PDF export, print, package | 8% | 60% | PDF/X-4 strong; no X-1a, presets, Windows printing |
|
||||
| Output: PDF export, print, package | 8% | 60% | PDF/X-4 strong; no X-1a or presets; physical print validation pending |
|
||||
| View, navigation, undo, save | 3% | 70% | |
|
||||
| **Weighted depth** | 100% | **60.7%** | |
|
||||
|
||||
@@ -152,7 +152,7 @@ features), exchange edge cases, and everything excluded above.
|
||||
| Undo / redo | 5% | 85% | |
|
||||
| Save and reopen | 5% | 85% | `.designcraft` round trip, recovery |
|
||||
| Export PDF / PNG | 10% | 80% | |
|
||||
| Print | 5% | 40% | PDF to `lpr` only, nothing on Windows |
|
||||
| Print | 5% | 40% | PDF to CUPS/Windows via craft-print; physical output validation pending |
|
||||
| **Weighted depth** | 100% | **72.0%** | |
|
||||
|
||||
| Discount | Factor | Evidence |
|
||||
@@ -217,7 +217,7 @@ table is the one summed.
|
||||
| Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Convert Rows, Edit Header/Footer |
|
||||
| Long documents | 100% | 45% | 30–50 | Books are shallow (sync, book pagination, book PDF/TOC/index); Table of Contents Styles |
|
||||
| Interactivity & digital | 100% | 40% | 30–50 | HTML export basic (#314); fixed-layout EPUB is page images; no animation, timing, media playback |
|
||||
| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), no PDF/print presets or `.joboptions`, print via `lpr` (no Windows), no trapping; Output Preview panel |
|
||||
| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), no PDF/print presets or `.joboptions`, print via craft-print (CUPS/Windows), no trapping; Output Preview panel |
|
||||
| XML & automation | 100% | 35% | (ecosystem dimension) | no ExtendScript/UXP; XML structure depth |
|
||||
| View & navigation | 100% | 65% | 10–20 | Navigator, display badges, Show/Hide extras (content grabber, live corners) |
|
||||
| Undo, history, saving | 100% | 70% | 5–10 | Undo/Redo name the action; File ▸ Open Recent |
|
||||
@@ -346,7 +346,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items.
|
||||
- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there).
|
||||
- Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate.
|
||||
- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) through the `hypher` crate; hyphenation for other languages isn't bundled yet.
|
||||
- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) and Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause), both through the `hypher` crate; hyphenation for other languages isn't bundled yet.
|
||||
- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF.
|
||||
- Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet.
|
||||
- Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change.
|
||||
|
||||
Reference in New Issue
Block a user