Merge branch 'print-color' into print-color-x1a

# Conflicts:
#	Cargo.toml
This commit is contained in:
aiongg
2026-10-26 01:11:11 +00:00
38 changed files with 1565 additions and 1092 deletions
Generated
+486 -363
View File
File diff suppressed because it is too large Load Diff
+16 -15
View File
@@ -12,9 +12,10 @@ repository = "https://github.com/storytold/designcraft"
[workspace.dependencies]
# Shared reader and writer; publish the immutable revision before this consumer.
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "e3b9e239c52c14677be462e94d8920ea243de4f3", version = "=0.1.0", default-features = false }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", version = "=0.1.0", default-features = false }
# Separations, ink coverage and the ink manager; same revision as craft-pdf and the krilla patch.
craft-prepress = { git = "https://github.com/storytold/craft-libs", rev = "e3b9e239c52c14677be462e94d8920ea243de4f3", version = "=0.1.0", features = ["flatten"] }
craft-prepress = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", version = "=0.1.0", features = ["flatten"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
# internal
designcraft-geom = { path = "crates/geom" }
designcraft-color = { path = "crates/color" }
@@ -77,20 +78,20 @@ zip = { version = "3", default-features = false, features = ["deflate"] }
indd = { git = "https://github.com/storytold/indd-utils", branch = "main" }
# Which paths stored in documents may be followed (craft-libs).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e3b9e239c52c14677be462e94d8920ea243de4f3" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
# Atomic file replacement for saves and exports (craft-libs).
craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e3b9e239c52c14677be462e94d8920ea243de4f3" }
craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
# File › Print through the system print spooler (craft-libs).
craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e3b9e239c52c14677be462e94d8920ea243de4f3" }
craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
# ICC colour management: working spaces, transforms, black point compensation (craft-libs).
craft-cms = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e3b9e239c52c14677be462e94d8920ea243de4f3" }
craft-cms = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
# ui
# Shared frontend components; keep this dependency above the engine boundary.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e3b9e239c52c14677be462e94d8920ea243de4f3", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["serde"] }
# The application bar as the window's title bar (craft-libs).
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e3b9e239c52c14677be462e94d8920ea243de4f3", features = ["egui"] }
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
egui = "0.37"
egui_extras = { version = "0.37", default-features = false, features = ["svg"] }
eframe = { version = "0.37", default-features = false }
@@ -128,14 +129,14 @@ debug = 0
# Opt-in cancelable macOS termination on the pinned winit 0.30 line.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "e3b9e239c52c14677be462e94d8920ea243de4f3", version = "=0.8.2" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", version = "=0.8.2" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
hayro = { git = "https://github.com/storytold/craft-libs", rev = "e3b9e239c52c14677be462e94d8920ea243de4f3", version = "=0.7.1" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "e3b9e239c52c14677be462e94d8920ea243de4f3", version = "=0.7.0" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "e3b9e239c52c14677be462e94d8920ea243de4f3", version = "=0.7.2" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "e3b9e239c52c14677be462e94d8920ea243de4f3", version = "=0.3.0" }
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "e3b9e239c52c14677be462e94d8920ea243de4f3", version = "=0.1.0" }
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "e3b9e239c52c14677be462e94d8920ea243de4f3", version = "=0.1.0" }
hayro = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", version = "=0.7.1" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", version = "=0.7.0" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", version = "=0.7.2" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", version = "=0.3.0" }
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", version = "=0.1.0" }
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", version = "=0.1.0" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+1
View File
@@ -32,6 +32,7 @@ arboard = { workspace = true }
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
# EnumDisplayDevices (src/gpu.rs): which GPU drives the display, so the window draws on the GPU
# that shows it; drawing on a GPU without a monitor reset the display driver. Safe wrappers over
# Win32 (MIT); the `user` feature is User32.dll only.
+6
View File
@@ -398,6 +398,11 @@ fn control_port_from(source: &str, value: Option<String>) -> Option<u16> {
}
fn main() -> std::process::ExitCode {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::DESIGNCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so every start-up record (and the engine's panic hook, installed with the first
// Session) is captured; see `logging`.
let logger = logging::install();
@@ -553,6 +558,7 @@ fn main() -> std::process::ExitCode {
session.recovery_dir = profile::recovery_dir();
let recovered = session.execute("file.recovery.open", &serde_json::json!({})).ok();
let mut app = DesignApp::new(session, services());
app.set_startup_update_check(true);
if let Some(line) = restarted.or_else(|| created.status_line()) {
app.status(line);
}
+48 -43
View File
@@ -17,6 +17,53 @@ struct Names<'a> {
}
impl Names<'_> {
/// Rename the swatch references in every paragraph, character, object, cell and table style.
fn styles(&self, styles: &mut crate::styles::Styles) {
let n = self;
for p in &mut styles.paragraph {
n.para(&mut p.para);
n.chars(&mut p.chars);
}
for c in &mut styles.character {
n.chars(&mut c.chars);
}
for o in &mut styles.object {
if let Some(f) = &mut o.fill {
n.fill(f);
}
if let Some(s) = &mut o.stroke {
n.stroke(s);
}
if let Some(tf) = &mut o.text_frame {
n.frame(tf);
}
}
for c in &mut styles.cell {
n.o(&mut c.fill);
if let Some(s) = &mut c.stroke {
n.cell_stroke(s);
}
for s in &mut c.strokes {
n.stroke_attrs(s);
}
}
for t in &mut styles.table {
if let Some(b) = &mut t.border {
n.cell_stroke(b);
}
for b in &mut t.borders {
n.stroke_attrs(b);
}
if let Some(f) = &mut t.alt_rows {
n.alt_fills(f);
}
n.alt_fill_attrs(&mut t.row_fills);
n.alt_fill_attrs(&mut t.column_fills);
n.alt_stroke_attrs(&mut t.row_strokes);
n.alt_stroke_attrs(&mut t.column_strokes);
}
}
fn s(&self, v: &mut String) {
if v == self.from {
*v = self.to.to_string();
@@ -217,49 +264,7 @@ impl Document {
n.s(b);
}
n.s(&mut self.footnote_options.rule.color);
let styles = self.styles_mut();
for p in &mut styles.paragraph {
n.para(&mut p.para);
n.chars(&mut p.chars);
}
for c in &mut styles.character {
n.chars(&mut c.chars);
}
for o in &mut styles.object {
if let Some(f) = &mut o.fill {
n.fill(f);
}
if let Some(s) = &mut o.stroke {
n.stroke(s);
}
if let Some(tf) = &mut o.text_frame {
n.frame(tf);
}
}
for c in &mut styles.cell {
n.o(&mut c.fill);
if let Some(s) = &mut c.stroke {
n.cell_stroke(s);
}
for s in &mut c.strokes {
n.stroke_attrs(s);
}
}
for t in &mut styles.table {
if let Some(b) = &mut t.border {
n.cell_stroke(b);
}
for b in &mut t.borders {
n.stroke_attrs(b);
}
if let Some(f) = &mut t.alt_rows {
n.alt_fills(f);
}
n.alt_fill_attrs(&mut t.row_fills);
n.alt_fill_attrs(&mut t.column_fills);
n.alt_stroke_attrs(&mut t.row_strokes);
n.alt_stroke_attrs(&mut t.column_strokes);
}
n.styles(self.styles_mut());
for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) {
for it in &mut Arc::make_mut(sp).items {
n.item(Arc::make_mut(it), 0);
+66 -56
View File
@@ -78,6 +78,70 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// The first open subpath (two anchors or more) of an item.
fn first_open_subpath(d: &Document, id: ItemId) -> Option<usize> {
d.item(id).and_then(|it| it.path.subpaths.iter().position(|sp| !sp.closed && sp.anchors.len() > 1))
}
/// Close every open subpath of one path.
fn close_open_subpaths(d: &mut Document, id: ItemId) -> Result<Value> {
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let mut closed = 0;
for sp in it.path.subpaths.iter_mut().filter(|sp| !sp.closed && sp.anchors.len() > 1) {
sp.closed = true;
closed += 1;
}
if closed == 0 {
return Err(bad("path.join", "the path is already closed"));
}
as_path(&mut it.shape);
Ok(json!({"id": id.0, "closed": closed}))
}
/// Join the open subpaths of two paths at their nearest ends, into the first path.
fn join_open_pair(d: &mut Document, sel: &mut designcraft_doc::Selection, a: ItemId, b: ItemId) -> Result<Value> {
let (Some(sa), Some(sb)) = (first_open_subpath(d, a), first_open_subpath(d, b)) else {
return Err(bad("path.join", "select two open paths"));
};
// b's path in a's space.
let inv_a = to_inner(d, a).ok_or(designcraft_doc::DocError::NoItem(a))?;
let xf_b = to_inner(d, b).ok_or(designcraft_doc::DocError::NoItem(b))?.inverse();
let m = inv_a * xf_b;
let mut pb = d.item(b).and_then(|it| it.path.subpaths.get(sb).cloned()).ok_or(designcraft_doc::DocError::NoItem(b))?;
for an in &mut pb.anchors {
*an = Anchor { p: m * an.p, h_in: m * an.h_in, h_out: m * an.h_out, kind: an.kind };
}
let ita = d.item_mut(a).ok_or(designcraft_doc::DocError::NoItem(a))?;
let pa = ita.path.subpaths.get_mut(sa).ok_or_else(|| bad("path.join", "no such subpath"))?;
// The pair of ends nearest each other; then a's end meets b's start.
let ends = |sp: &designcraft_geom::SubPath| (sp.anchors.first().map(|x| x.p), sp.anchors.last().map(|x| x.p));
let ((Some(a0), Some(a1)), (Some(b0), Some(b1))) = (ends(pa), ends(&pb)) else {
return Err(bad("path.join", "select two open paths"));
};
let pairs = [(a1, b0, false, false), (a1, b1, false, true), (a0, b0, true, false), (a0, b1, true, true)];
let Some(&(_, _, rev_a, rev_b)) = pairs.iter().min_by(|x, y| (x.0 - x.1).hypot().total_cmp(&(y.0 - y.1).hypot())) else {
return Err(bad("path.join", "select two open paths"));
};
if rev_a {
pa.reverse();
}
if rev_b {
pb.reverse();
}
let mut rest = pb.anchors.into_iter();
if let Some(first) = rest.next() {
match pa.anchors.last_mut() {
Some(last) if (last.p - first.p).hypot() < 0.01 => *last = Anchor::with_handles(last.p, last.h_in, first.h_out),
_ => pa.anchors.push(first),
}
}
pa.anchors.extend(rest);
as_path(&mut ita.shape);
d.remove_item(b)?;
*sel = designcraft_doc::Selection::items(vec![a]);
Ok(json!({"id": a.0}))
}
/// Join Path: close one open path, or join two open paths at their nearest ends.
fn join_paths(s: &mut Session, p: &Value) -> Result<Value> {
let ids = super::targets(s, p)?;
@@ -90,63 +154,9 @@ fn join_paths(s: &mut Session, p: &Value) -> Result<Value> {
path_editable(d, *id, "path.join")?;
}
}
let open = |d: &Document, id: ItemId| d.item(id).and_then(|it| it.path.subpaths.iter().position(|sp| !sp.closed && sp.anchors.len() > 1));
match ids.as_slice() {
[id] => {
let it = d.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
let mut closed = 0;
for sp in it.path.subpaths.iter_mut().filter(|sp| !sp.closed && sp.anchors.len() > 1) {
sp.closed = true;
closed += 1;
}
if closed == 0 {
return Err(bad("path.join", "the path is already closed"));
}
as_path(&mut it.shape);
Ok(json!({"id": id.0, "closed": closed}))
}
[a, b] => {
let (Some(sa), Some(sb)) = (open(d, *a), open(d, *b)) else {
return Err(bad("path.join", "select two open paths"));
};
// b's path in a's space.
let inv_a = to_inner(d, *a).ok_or(designcraft_doc::DocError::NoItem(*a))?;
let xf_b = to_inner(d, *b).ok_or(designcraft_doc::DocError::NoItem(*b))?.inverse();
let m = inv_a * xf_b;
let mut pb = d.item(*b).and_then(|it| it.path.subpaths.get(sb).cloned()).ok_or(designcraft_doc::DocError::NoItem(*b))?;
for an in &mut pb.anchors {
*an = Anchor { p: m * an.p, h_in: m * an.h_in, h_out: m * an.h_out, kind: an.kind };
}
let ita = d.item_mut(*a).ok_or(designcraft_doc::DocError::NoItem(*a))?;
let pa = ita.path.subpaths.get_mut(sa).ok_or_else(|| bad("path.join", "no such subpath"))?;
// The pair of ends nearest each other; then a's end meets b's start.
let ends = |sp: &designcraft_geom::SubPath| (sp.anchors.first().map(|x| x.p), sp.anchors.last().map(|x| x.p));
let ((Some(a0), Some(a1)), (Some(b0), Some(b1))) = (ends(pa), ends(&pb)) else {
return Err(bad("path.join", "select two open paths"));
};
let pairs = [(a1, b0, false, false), (a1, b1, false, true), (a0, b0, true, false), (a0, b1, true, true)];
let Some(&(_, _, rev_a, rev_b)) = pairs.iter().min_by(|x, y| (x.0 - x.1).hypot().total_cmp(&(y.0 - y.1).hypot())) else {
return Err(bad("path.join", "select two open paths"));
};
if rev_a {
pa.reverse();
}
if rev_b {
pb.reverse();
}
let mut rest = pb.anchors.into_iter();
if let Some(first) = rest.next() {
match pa.anchors.last_mut() {
Some(last) if (last.p - first.p).hypot() < 0.01 => *last = Anchor::with_handles(last.p, last.h_in, first.h_out),
_ => pa.anchors.push(first),
}
}
pa.anchors.extend(rest);
as_path(&mut ita.shape);
d.remove_item(*b)?;
*sel = designcraft_doc::Selection::items(vec![*a]);
Ok(json!({"id": a.0}))
}
[id] => close_open_subpaths(d, *id),
[a, b] => join_open_pair(d, sel, *a, *b),
_ => Err(bad("path.join", "select one or two open paths")),
}
})
+77 -53
View File
@@ -128,21 +128,33 @@ struct Rep {
text: String,
}
/// Where `fill_row` looks for linked images: the merge options and the two search folders.
#[derive(Clone, Copy)]
pub struct Sources<'a> {
pub options: &'a MergeOptions,
pub data_dir: Option<&'a Path>,
pub doc_dir: Option<&'a Path>,
}
/// Replacements and hyperlinks collected while walking the placeholders of one record.
#[derive(Default)]
struct Pending {
reps: Vec<Rep>,
links: Vec<(PlaceholderAnchor, HyperlinkDest, String)>,
parent: bool,
report: FillReport,
}
/// Fill `row` (0-based) into the items `maps` points at. `record` is the 1-based source row.
pub fn fill_row(
doc: &mut designcraft_doc::Document,
fields: &[DataField],
row: &[String],
record: u32,
options: &MergeOptions,
maps: &Maps,
data_dir: Option<&Path>,
doc_dir: Option<&Path>,
sources: &Sources,
) -> FillReport {
let mut report = FillReport::default();
let mut parent = false;
let mut reps: Vec<Rep> = Vec::new();
let mut links: Vec<(PlaceholderAnchor, HyperlinkDest, String)> = Vec::new();
let mut pending = Pending::default();
let placeholders = doc.data_merge.placeholders.clone();
for ph in &placeholders {
if !fields.iter().any(|f| f.name == ph.field) {
@@ -150,43 +162,11 @@ pub fn fill_row(
}
let cell = cell_of(fields, row, &ph.field).to_string();
match &ph.anchor {
PlaceholderAnchor::Text { story, start, end } => {
let Some(&mapped) = maps.stories.get(story) else {
if anchor_is_parent(doc, &ph.anchor) {
parent = true;
}
continue;
};
match ph.role {
PlaceholderRole::Text => reps.push(Rep { story: mapped, start: *start, end: *end, text: cell }),
PlaceholderRole::Hyperlink => {
if let Some(dest) = classify_link(&cell) {
links.push((PlaceholderAnchor::Text { story: mapped, start: *start, end: *end }, dest, ph.field.clone()));
}
}
PlaceholderRole::Image | PlaceholderRole::Qr => {}
}
}
PlaceholderAnchor::Item { id } => {
let Some(&mapped) = maps.items.get(id) else {
if anchor_is_parent(doc, &ph.anchor) {
parent = true;
}
continue;
};
match ph.role {
PlaceholderRole::Image => place_image(doc, mapped, &ph.field, &cell, record, options, data_dir, doc_dir, &mut report),
PlaceholderRole::Qr => place_qr(doc, mapped, &ph.field, &cell, record, &mut report),
PlaceholderRole::Hyperlink => {
if let Some(dest) = classify_link(&cell) {
links.push((PlaceholderAnchor::Item { id: mapped }, dest, ph.field.clone()));
}
}
PlaceholderRole::Text => {}
}
}
PlaceholderAnchor::Text { story, start, end } => plan_text(doc, maps, ph, (*story, *start, *end), cell, &mut pending),
PlaceholderAnchor::Item { .. } => plan_item(doc, maps, ph, &cell, record, sources, &mut pending),
}
}
let Pending { mut reps, links, parent, mut report } = pending;
typed_reps(doc, fields, row, maps, &placeholders, &mut reps);
apply_reps(doc, &reps);
for (anchor, dest, field) in links {
@@ -207,6 +187,59 @@ pub fn fill_row(
report
}
fn plan_text(
doc: &designcraft_doc::Document,
maps: &Maps,
ph: &designcraft_doc::Placeholder,
(story, start, end): (StoryId, usize, usize),
cell: String,
pending: &mut Pending,
) {
let Some(&mapped) = maps.stories.get(&story) else {
if anchor_is_parent(doc, &ph.anchor) {
pending.parent = true;
}
return;
};
match ph.role {
PlaceholderRole::Text => pending.reps.push(Rep { story: mapped, start, end, text: cell }),
PlaceholderRole::Hyperlink => {
if let Some(dest) = classify_link(&cell) {
pending.links.push((PlaceholderAnchor::Text { story: mapped, start, end }, dest, ph.field.clone()));
}
}
PlaceholderRole::Image | PlaceholderRole::Qr => {}
}
}
fn plan_item(
doc: &mut designcraft_doc::Document,
maps: &Maps,
ph: &designcraft_doc::Placeholder,
cell: &str,
record: u32,
sources: &Sources,
pending: &mut Pending,
) {
let PlaceholderAnchor::Item { id } = &ph.anchor else { return };
let Some(&mapped) = maps.items.get(id) else {
if anchor_is_parent(doc, &ph.anchor) {
pending.parent = true;
}
return;
};
match ph.role {
PlaceholderRole::Image => place_image(doc, mapped, &ph.field, cell, record, sources, &mut pending.report),
PlaceholderRole::Qr => place_qr(doc, mapped, &ph.field, cell, record, &mut pending.report),
PlaceholderRole::Hyperlink => {
if let Some(dest) = classify_link(cell) {
pending.links.push((PlaceholderAnchor::Item { id: mapped }, dest, ph.field.clone()));
}
}
PlaceholderRole::Text => {}
}
}
fn typed_reps(
doc: &designcraft_doc::Document,
fields: &[DataField],
@@ -326,17 +359,8 @@ fn create_link(doc: &mut designcraft_doc::Document, anchor: PlaceholderAnchor, d
true
}
fn place_image(
doc: &mut designcraft_doc::Document,
item: ItemId,
field: &str,
cell: &str,
record: u32,
options: &MergeOptions,
data_dir: Option<&Path>,
doc_dir: Option<&Path>,
report: &mut FillReport,
) {
fn place_image(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, cell: &str, record: u32, sources: &Sources, report: &mut FillReport) {
let Sources { options, data_dir, doc_dir } = *sources;
let cands = image_candidates(cell, data_dir, doc_dir);
let found = cands.iter().find_map(|p| {
// Refuse huge files before reading them; a too-large image counts as missing.
+35 -55
View File
@@ -18,7 +18,7 @@ use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, id_param, ok, str_param};
use crate::{DocState, Result, Session};
use fill::{FillReport, Maps, MissingImage, copy_item, fill_row, identity_map};
use fill::{FillReport, Maps, MissingImage, Sources, copy_item, fill_row, identity_map};
use parse::{
Table, TileInput, extension, fingerprint_of, relative_between, select_records, status_of, table_from_bytes, table_from_grid, table_from_objects,
tile,
@@ -210,7 +210,8 @@ fn preview_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let row = source.rows.get((record as usize).saturating_sub(1)).cloned().unwrap_or_default();
let data_dir = source_file(&source, st.path.as_deref().map(Path::new)).as_deref().and_then(Path::parent).map(Path::to_path_buf);
let doc_dir = st.path.as_deref().and_then(parent_dir);
fill_row(&mut doc, &source.fields, &row, record as u32, &options, &maps, data_dir.as_deref(), doc_dir.as_deref());
let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref() };
fill_row(&mut doc, &source.fields, &row, record as u32, &maps, &sources);
st.doc = Arc::new(doc);
st.preview_record = Some(record as u32);
st.bump_revision();
@@ -245,10 +246,12 @@ fn merge_cmd(s: &mut Session, p: &Value) -> Result<Value> {
warnings.extend(table.warnings.iter().cloned());
let mut merged = src.clone();
let mut missing = Vec::new();
let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref() };
let mut sink = Sink { warnings: &mut warnings, missing: &mut missing, parent: false };
if options.per_page == "multiple" {
tile_records(&mut merged, &src, &table, &selected, &options, data_dir.as_deref(), doc_dir.as_deref(), &mut warnings, &mut missing)?;
tile_records(&mut merged, &src, &table, &selected, &sources, &mut sink)?;
} else {
copy_records(&mut merged, &src, &table, &selected, &options, data_dir.as_deref(), doc_dir.as_deref(), &mut warnings, &mut missing)?;
copy_records(&mut merged, &src, &table, &selected, &sources, &mut sink)?;
}
// Status is written only after every error return. The new document then becomes active,
// so this template edit does not push an undo step. The fingerprint stays the last read.
@@ -272,25 +275,14 @@ fn merge_cmd(s: &mut Session, p: &Value) -> Result<Value> {
}))
}
fn copy_records(
dst: &mut Document,
src: &Document,
table: &Table,
selected: &[usize],
options: &MergeOptions,
data_dir: Option<&Path>,
doc_dir: Option<&Path>,
warnings: &mut Vec<String>,
missing: &mut Vec<MissingImage>,
) -> Result<()> {
let mut parent = false;
fn copy_records(dst: &mut Document, src: &Document, table: &Table, selected: &[usize], sources: &Sources, sink: &mut Sink) -> Result<()> {
if let Some(&idx) = selected.first() {
let maps = identity_map(dst);
fill_at(dst, table, idx, options, &maps, data_dir, doc_dir, warnings, missing, &mut parent);
fill_at(dst, table, idx, sources, &maps, sink);
}
for &idx in selected.iter().skip(1) {
let maps = append_copy(dst, src)?;
fill_at(dst, table, idx, options, &maps, data_dir, doc_dir, warnings, missing, &mut parent);
fill_at(dst, table, idx, sources, &maps, sink);
}
Ok(())
}
@@ -320,17 +312,8 @@ fn append_copy(dst: &mut Document, src: &Document) -> Result<Maps> {
Ok(maps)
}
fn tile_records(
dst: &mut Document,
src: &Document,
table: &Table,
selected: &[usize],
options: &MergeOptions,
data_dir: Option<&Path>,
doc_dir: Option<&Path>,
warnings: &mut Vec<String>,
missing: &mut Vec<MissingImage>,
) -> Result<()> {
fn tile_records(dst: &mut Document, src: &Document, table: &Table, selected: &[usize], sources: &Sources, sink: &mut Sink) -> Result<()> {
let options = sources.options;
let (block, ids) = block_of(src);
let (width, height) = src.page(0).map(|p| (p.width, p.height)).unwrap_or((612.0, 792.0));
let plan = tile(&TileInput {
@@ -344,7 +327,7 @@ fn tile_records(
count: selected.len(),
});
if plan.one_per_page {
warnings.push("Only one record fits on the page, so each record has its own page.".into());
sink.warnings.push("Only one record fits on the page, so each record has its own page.".into());
}
if plan.pages > 1 {
let parent = src.page(0).and_then(|p| p.parent);
@@ -356,12 +339,11 @@ fn tile_records(
}
}
}
let mut parent = false;
for hit in plan.hits {
let Some(&idx) = selected.get(hit.index) else { continue };
if hit.index == 0 && hit.page == 0 && hit.col == 0 && hit.row == 0 {
let maps = identity_map(dst);
fill_at(dst, table, idx, options, &maps, data_dir, doc_dir, warnings, missing, &mut parent);
fill_at(dst, table, idx, sources, &maps, sink);
continue;
}
let Some((dsi, _)) = dst.page_loc(hit.page) else { continue };
@@ -370,39 +352,37 @@ fn tile_records(
let Some(it) = src.item(*id) else { continue };
copy_item(dst, src, it, SpreadRef::Doc(dsi), Vec2::new(hit.dx, hit.dy), &mut maps)?;
}
fill_at(dst, table, idx, options, &maps, data_dir, doc_dir, warnings, missing, &mut parent);
fill_at(dst, table, idx, sources, &maps, sink);
}
Ok(())
}
fn fill_at(
doc: &mut Document,
table: &Table,
idx: usize,
options: &MergeOptions,
maps: &Maps,
data_dir: Option<&Path>,
doc_dir: Option<&Path>,
warnings: &mut Vec<String>,
missing: &mut Vec<MissingImage>,
parent: &mut bool,
) {
fn fill_at(doc: &mut Document, table: &Table, idx: usize, sources: &Sources, maps: &Maps, sink: &mut Sink) {
let Some(row) = table.rows.get(idx) else { return };
let report = fill_row(doc, &table.fields, row, (idx as u32).saturating_add(1), options, maps, data_dir, doc_dir);
absorb(report, warnings, missing, parent);
let report = fill_row(doc, &table.fields, row, (idx as u32).saturating_add(1), maps, sources);
sink.absorb(report);
}
fn absorb(report: FillReport, warnings: &mut Vec<String>, missing: &mut Vec<MissingImage>, parent: &mut bool) {
for w in report.warnings {
if w == PARENT_WARNING {
if *parent {
continue;
/// Warnings and missing images collected across every record, with the parent-page warning kept to one.
struct Sink<'a> {
warnings: &'a mut Vec<String>,
missing: &'a mut Vec<MissingImage>,
parent: bool,
}
impl Sink<'_> {
fn absorb(&mut self, report: FillReport) {
for w in report.warnings {
if w == PARENT_WARNING {
if self.parent {
continue;
}
self.parent = true;
}
*parent = true;
self.warnings.push(w);
}
warnings.push(w);
self.missing.extend(report.missing);
}
missing.extend(report.missing);
}
fn block_of(doc: &Document) -> (Rect, Vec<ItemId>) {
+37 -33
View File
@@ -128,7 +128,7 @@ pub fn table_from_objects(rows: &[Value]) -> Result<Table, String> {
warnings.push(format!("Row {} has an extra field \"{key}\". It was ignored.", i + 1));
}
}
if cells.iter().all(|c| c.is_empty()) {
if cells.iter().all(String::is_empty) {
continue;
}
out.push(cells);
@@ -159,38 +159,7 @@ pub fn select_records(count: usize, records: &str, one: u32, range: &str, limit:
let i = record_index(one, count)?;
idx.push(i);
}
"range" => {
if range.trim().is_empty() {
return Err("no records".into());
}
for token in range.split(',') {
let token = token.trim();
if token.is_empty() {
continue;
}
if let Some((a, b)) = token.split_once('-') {
let a = parse_record_no(a.trim())?;
let b = parse_record_no(b.trim())?;
if a > b {
return Err(format!("range {a}-{b} is reversed"));
}
let mut n = a;
while n <= b {
let i = record_index(n, count)?;
if seen.insert(i) {
idx.push(i);
}
n = n.saturating_add(1);
}
} else {
let n = parse_record_no(token)?;
let i = record_index(n, count)?;
if seen.insert(i) {
idx.push(i);
}
}
}
}
"range" => select_range(range, count, &mut idx, &mut seen)?,
other => return Err(format!("unknown records value \"{other}\"")),
}
if let Some(limit) = limit {
@@ -205,6 +174,41 @@ pub fn select_records(count: usize, records: &str, one: u32, range: &str, limit:
Ok(idx)
}
/// Expand a comma-separated list of record numbers and `a-b` ranges into `idx`, skipping repeats.
fn select_range(range: &str, count: usize, idx: &mut Vec<usize>, seen: &mut std::collections::HashSet<usize>) -> Result<(), String> {
if range.trim().is_empty() {
return Err("no records".into());
}
for token in range.split(',') {
let token = token.trim();
if token.is_empty() {
continue;
}
let (a, b) = match token.split_once('-') {
Some((a, b)) => (parse_record_no(a.trim())?, parse_record_no(b.trim())?),
None => {
let n = parse_record_no(token)?;
(n, n)
}
};
if a > b {
return Err(format!("range {a}-{b} is reversed"));
}
let mut n = a;
while n <= b {
let i = record_index(n, count)?;
if seen.insert(i) {
idx.push(i);
}
if n == b {
break;
}
n += 1;
}
}
Ok(())
}
fn parse_record_no(s: &str) -> Result<u32, String> {
s.parse::<u32>().map_err(|_| format!("bad record \"{s}\""))
}
@@ -3,7 +3,7 @@
//! These helpers write real files, so they live in their own `_tests.rs` file: the
//! file-system guard in `tests_file_guard.rs` checks `src` files but exempts test ones.
use super::*;
use super::{Arc, AssetId, Content, ItemId, Session, base64_encode, json};
pub(crate) fn tiff_eps() -> Vec<u8> {
let mut tiff = b"II".to_vec();
+27 -17
View File
@@ -323,18 +323,38 @@ fn replacement(p: &Value) -> String {
if bool_or(p, "grep", false) { translate_replacement(c) } else { metachars(c) }
}
/// Repair byte validity of a text selection once against the final story, without remapping valid offsets.
fn repair_selection(t: &mut designcraft_doc::TextSel, st: &designcraft_doc::Story) {
let range = t.range();
let start = floor_char_boundary(&st.text, range.start);
let mut end = range.end.min(st.len());
if t.is_caret() {
end = start;
} else if !st.text.is_char_boundary(end) {
let floor = floor_char_boundary(&st.text, end);
end = st.text.get(floor..).and_then(|rest| rest.chars().next()).map_or(floor, |ch| floor + ch.len_utf8());
}
(t.anchor, t.focus) = if t.anchor <= t.focus { (start, end) } else { (end, start) };
}
/// The character overrides a Change applies to every replacement, from its `attrs` object.
fn char_overrides(attrs: Option<&Value>) -> Result<designcraft_doc::CharAttrs> {
let mut over = designcraft_doc::CharAttrs::default();
if let Some(o) = attrs.and_then(Value::as_object) {
for (k, v) in o {
over.set_json(k, v).map_err(|e| bad("find.change", e))?;
}
}
Ok(over)
}
fn change(s: &mut Session, p: &Value) -> Result<Value> {
let re = build(p)?;
let grep = bool_or(p, "grep", false);
let rep = replacement(p);
let first_only = bool_or(p, "first", false);
let attrs = p.get("attrs").cloned();
let mut over = designcraft_doc::CharAttrs::default();
if let Some(o) = attrs.as_ref().and_then(Value::as_object) {
for (k, v) in o {
over.set_json(k, v).map_err(|e| bad("find.change", e))?;
}
}
let over = char_overrides(attrs.as_ref())?;
super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?;
let targets = scope(s, p)?.targets;
s.edit(|d, sel| {
@@ -361,17 +381,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
count += 1;
}
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) {
// Repair byte validity once against the final story, without remapping valid offsets.
let range = t.range();
let start = floor_char_boundary(&st.text, range.start);
let mut end = range.end.min(st.len());
if t.is_caret() {
end = start;
} else if !st.text.is_char_boundary(end) {
let floor = floor_char_boundary(&st.text, end);
end = st.text.get(floor..).and_then(|rest| rest.chars().next()).map_or(floor, |ch| floor + ch.len_utf8());
}
(t.anchor, t.focus) = if t.anchor <= t.focus { (start, end) } else { (end, start) };
repair_selection(t, st);
}
// `first` is the first match of the whole search, not of each story.
if first_only && count > 0 {
+31 -26
View File
@@ -81,36 +81,41 @@ fn list(d: &Document) -> Vec<Value> {
used_fonts(d).into_iter().map(|font| json!(font)).collect()
}
pub(super) fn used_fonts(d: &Document) -> Vec<UsedFont> {
let db = designcraft_fonts::FontDb::global().scoped(d.font_scope);
let mut used: BTreeMap<(String, String), (usize, std::collections::BTreeSet<char>)> = BTreeMap::new();
for_each_story(d, &mut |st| {
let ranges = st.para_ranges();
for (pi, r) in ranges.iter().enumerate() {
let (_, base) = d.styles.resolve_para(&st.paras[pi]);
for (rr, fmt) in st.runs() {
let n = st.text[rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))].chars().count();
if n == 0 && !(r.is_empty() && rr.contains(&r.start)) {
continue;
}
let p = d.styles.resolve_char(&base, fmt);
if let Some(f) = d.styles.composite_font(&p.font_family) {
let text = st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or("");
for c in text.chars() {
if let Some(e) = f.entry(c) {
let entry = used.entry((e.family.clone(), e.style.clone())).or_default();
entry.0 += 1;
entry.1.insert(c);
}
type FontTally = BTreeMap<(String, String), (usize, std::collections::BTreeSet<char>)>;
/// Count the characters of every run of one story against the font each resolves to.
fn tally_story(d: &Document, st: &Story, used: &mut FontTally) {
let ranges = st.para_ranges();
for (pi, r) in ranges.iter().enumerate() {
let (_, base) = d.styles.resolve_para(&st.paras[pi]);
for (rr, fmt) in st.runs() {
let n = st.text[rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))].chars().count();
if n == 0 && !(r.is_empty() && rr.contains(&r.start)) {
continue;
}
let p = d.styles.resolve_char(&base, fmt);
if let Some(f) = d.styles.composite_font(&p.font_family) {
let text = st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or("");
for c in text.chars() {
if let Some(e) = f.entry(c) {
let entry = used.entry((e.family.clone(), e.style.clone())).or_default();
entry.0 += 1;
entry.1.insert(c);
}
} else {
let entry = used.entry((p.font_family, p.font_style)).or_default();
entry.0 += n;
entry.1.extend(st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or("").chars());
}
} else {
let entry = used.entry((p.font_family, p.font_style)).or_default();
entry.0 += n;
entry.1.extend(st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or("").chars());
}
}
});
}
}
pub(super) fn used_fonts(d: &Document) -> Vec<UsedFont> {
let db = designcraft_fonts::FontDb::global().scoped(d.font_scope);
let mut used = FontTally::new();
for_each_story(d, &mut |st| tally_story(d, st, &mut used));
let mut out: Vec<UsedFont> = used
.into_iter()
.map(|((family, style), (n, characters))| {
+43 -30
View File
@@ -762,6 +762,48 @@ fn redefine(s: &mut Session, p: &Value, paragraph: bool) -> Result<Value> {
result
}
/// Apply paragraph style `name` over `r` in one story; `clear` drops overrides instead of keeping
/// only those that differ from the style (`resolved` is the style's paragraph and character attributes).
fn apply_para_in_story(
st: &mut designcraft_doc::Story,
r: &std::ops::Range<usize>,
name: &str,
clear: bool,
resolved: (&designcraft_doc::ParaProps, &designcraft_doc::CharProps),
styles: &designcraft_doc::Styles,
) {
let (style_para, style_chars) = resolved;
// Text selections are half-open: a selection ending at the next paragraph's
// start must not apply a style to that paragraph too.
let end = if r.is_empty() { r.start } else { r.end.saturating_sub(1) };
let (first, last) = (st.para_at(r.start), st.para_at(end));
st.format_paras(r.start..end, |f| {
f.style = name.to_string();
if clear {
f.para = ParaAttrs::default();
f.chars = CharAttrs::default();
} else {
f.para.retain_differences(style_para);
f.chars.retain_differences(style_chars);
}
});
for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) {
let Some(pf) = st.paras.get(pi) else { continue };
let (_, base) = styles.resolve_para(pf);
let end = range.end.saturating_add(1).min(st.len());
let range = if clear && !r.is_empty() { range.start.max(r.start)..end.min(r.end) } else { range.start..end };
st.format_chars(range, |f| {
if clear {
f.over = CharAttrs::default();
} else {
let mut inherited = base.clone();
inherited.apply(&styles.char_style_attrs(&f.style));
f.over.retain_differences(&inherited);
}
});
}
}
fn apply_para(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.apply", "missing name"))?.to_string();
let clear = p.get("clearOverrides").and_then(Value::as_bool).unwrap_or(false);
@@ -773,37 +815,8 @@ fn apply_para(s: &mut Session, p: &Value) -> Result<Value> {
let styles = d.styles.clone();
let (style_para, style_chars) = styles.resolve_para_style(&name);
for t in &targets {
let r = &t.range;
if let Some(st) = d.text_story_mut(t.story, t.cell) {
// Text selections are half-open: a selection ending at the next paragraph's
// start must not apply a style to that paragraph too.
let end = if r.is_empty() { r.start } else { r.end.saturating_sub(1) };
let (first, last) = (st.para_at(r.start), st.para_at(end));
st.format_paras(r.start..end, |f| {
f.style = name.clone();
if clear {
f.para = ParaAttrs::default();
f.chars = CharAttrs::default();
} else {
f.para.retain_differences(&style_para);
f.chars.retain_differences(&style_chars);
}
});
for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) {
let Some(pf) = st.paras.get(pi) else { continue };
let (_, base) = styles.resolve_para(pf);
let end = range.end.saturating_add(1).min(st.len());
let range = if clear && !r.is_empty() { range.start.max(r.start)..end.min(r.end) } else { range.start..end };
st.format_chars(range, |f| {
if clear {
f.over = CharAttrs::default();
} else {
let mut inherited = base.clone();
inherited.apply(&styles.char_style_attrs(&f.style));
f.over.retain_differences(&inherited);
}
});
}
apply_para_in_story(st, &t.range, &name, clear, (&style_para, &style_chars), &styles);
}
}
ok()
+1 -1
View File
@@ -4,7 +4,7 @@
use designcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use serde_json::json;
use super::*;
use super::{Arc, Session, ViewInfo, ime_delete_ranges};
fn session() -> Session {
let mut s = Session::new();
+1 -1
View File
@@ -1,6 +1,6 @@
use serde_json::json;
use super::*;
use super::{Session, doc};
fn session_with_frame() -> (Session, u64, u64) {
let mut s = Session::new();
+15 -10
View File
@@ -983,6 +983,20 @@ impl<'a, 'f> StoryWriter<'a, 'f> {
out
}
/// One table cell with its row and column spans, as `th` or `td`.
fn table_cell(&mut self, text: &Story, cell_tag: &str, (rs, cs): (usize, usize), depth: usize, out: &mut String) {
let _ = write!(out, "<{cell_tag}");
if rs > 1 {
let _ = write!(out, " rowspan=\"{rs}\"");
}
if cs > 1 {
let _ = write!(out, " colspan=\"{cs}\"");
}
out.push('>');
self.paragraphs(text, depth, "", out);
let _ = writeln!(out, "</{cell_tag}>");
}
/// A table: header rows as `thead`, footer rows as `tfoot`, merged cells as row and column
/// spans (kept within their row group).
fn table(&mut self, t: &Table, depth: usize, out: &mut String) {
@@ -1016,16 +1030,7 @@ impl<'a, 'f> StoryWriter<'a, 'f> {
}
}
}
let _ = write!(out, "<{cell_tag}");
if rs > 1 {
let _ = write!(out, " rowspan=\"{rs}\"");
}
if cs > 1 {
let _ = write!(out, " colspan=\"{cs}\"");
}
out.push('>');
self.paragraphs(&cell.text, depth, "", out);
let _ = writeln!(out, "</{cell_tag}>");
self.table_cell(&cell.text, cell_tag, (rs, cs), depth, out);
}
out.push_str("</tr>\n");
}
+1 -1
View File
@@ -4,7 +4,7 @@
use std::path::{Path, PathBuf};
use super::*;
use super::{Arc, DOCUMENT_FONTS_FOLDER, FALLBACK_FAMILY, FontDb, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES};
use crate::testing::{font_with, font_with_names, font_with_wws, with_fs_type};
/// An empty temporary folder unique to `name` and this process.
+1 -1
View File
@@ -1,6 +1,6 @@
//! CJK fallback follows the character's language (and the script where it is unambiguous).
use super::*;
use super::FontDb;
use crate::testing::font_with;
const HAN: char = '直';
+2 -1
View File
@@ -3,9 +3,10 @@
//! The synthetic font (Source Sans 3 with `vhea`/`vmtx`, and `VORG` where asked) runs everywhere;
//! the checks against Shippori Mincho's own tables run when built with craft-fonts.
use skrifa::MetadataProvider;
use skrifa::raw::TableProvider;
use super::*;
use super::{Arc, FontBytes, FontDb, FontFace, FontSource, GlyphId, LocationRef, Size, make_face};
use crate::testing::{font_mapping, font_with, with_base, with_code_pages, with_table_len, with_table_u16, with_vmtx, with_vorg};
use crate::{ShapeContext, feature, shape_vertical, shape_with_context};
+68 -58
View File
@@ -440,6 +440,51 @@ impl<'a> Ex<'a> {
)
}
/// A `Gradient` element; stop colours missing from the swatches become anonymous colours.
fn gradient_el(
&mut self,
s: &designcraft_color::Swatch,
gradient: &designcraft_color::Gradient,
colors: &mut Vec<El>,
by_value: &mut Vec<(Color, String)>,
) -> El {
let id = self.sw(&s.name);
let mut g = El::new("Gradient")
.attr("Self", &id)
.attr("Type", if gradient.kind == GradientKind::Radial { "Radial" } else { "Linear" })
.attr("Name", &s.name)
.attr("ColorEditable", "true")
.attr("ColorRemovable", "true")
.attr("Visible", "true")
.attr("SwatchCreatorID", "7937");
let d = self.d;
for (i, stop) in gradient.stops.iter().enumerate() {
// A stop on a swatch names it; a plain color names an equal color swatch or
// an unnamed color.
let named =
stop.swatch.as_deref().filter(|n| d.swatch(n).is_some_and(|w| !matches!(w.value, SwatchValue::Gradient { .. } | SwatchValue::None)));
let cid = match (named, by_value.iter().find(|(c, _)| *c == stop.color)) {
(Some(n), _) => self.sw(n),
(None, Some((_, id))) => id.clone(),
(None, None) => {
let cid = format!("Color/{}", self.fresh());
colors.push(color_el(&cid, "$ID/", "Process", &stop.color, "Normal", (true, true, false)));
by_value.push((stop.color, cid.clone()));
cid
}
};
let mut e = El::new("GradientStop")
.attr("Self", format!("{}GradientStop{i}", id.replace(['/', ' '], "_")))
.attr("StopColor", cid)
.attr("Location", pct(stop.offset as f64));
if i > 0 {
e.set("Midpoint", pct(gradient.stops[i - 1].midpoint as f64));
}
g.push(e);
}
g
}
// ---------- Graphic.xml ----------
fn graphic_part(&mut self) -> Vec<u8> {
@@ -449,39 +494,21 @@ impl<'a> Ex<'a> {
let mut tints: Vec<El> = Vec::new();
let mut gradients: Vec<El> = Vec::new();
let mut inks: Vec<El> = Vec::new();
let color_el = |self_id: &str, name: &str, model: &str, c: &Color, over: &str, editable: bool, removable: bool, visible: bool| {
let (space, val) = color_value(c);
El::new("Color")
.attr("Self", self_id)
.attr("Model", model)
.attr("Space", space)
.attr("ColorValue", val)
.attr("ColorOverride", over)
.attr("AlternateSpace", "NoAlternateColor")
.attr("AlternateColorValue", "")
.attr("Name", name)
.attr("ColorEditable", bool_s(editable))
.attr("ColorRemovable", bool_s(removable))
.attr("Visible", bool_s(visible))
.attr("SwatchCreatorID", "7937")
};
// Specials (always present).
colors.push(color_el("Color/Black", "Black", "Process", &Color::cmyk(0.0, 0.0, 0.0, 1.0), "Specialblack", false, false, true));
colors.push(color_el("Color/Black", "Black", "Process", &Color::cmyk(0.0, 0.0, 0.0, 1.0), "Specialblack", (false, false, true)));
let paper = match d.swatch(swatch::PAPER).map(|s| &s.value) {
Some(SwatchValue::Paper { color }) => *color,
_ => Color::cmyk(0.0, 0.0, 0.0, 0.0),
};
let paper = if paper == Color::WHITE { Color::cmyk(0.0, 0.0, 0.0, 0.0) } else { paper };
colors.push(color_el("Color/Paper", "Paper", "Process", &paper, "Specialpaper", true, false, true));
colors.push(color_el("Color/Paper", "Paper", "Process", &paper, "Specialpaper", (true, false, true)));
colors.push(color_el(
"Color/Registration",
"Registration",
"Registration",
&Color::cmyk(1.0, 1.0, 1.0, 1.0),
"Specialregistration",
false,
false,
true,
(false, false, true),
));
for (i, ink) in
[("Process Cyan", 75, 0.61), ("Process Magenta", 15, 0.76), ("Process Yellow", 0, 0.16), ("Process Black", 45, 1.7)].iter().enumerate()
@@ -509,7 +536,7 @@ impl<'a> Ex<'a> {
match &s.value {
SwatchValue::Color { color, color_type } => {
let model = if *color_type == ColorType::Spot { "Spot" } else { "Process" };
colors.push(color_el(&id, &s.name, model, color, "Normal", true, true, true));
colors.push(color_el(&id, &s.name, model, color, "Normal", (true, true, true)));
by_value.push((*color, id.clone()));
if *color_type == ColorType::Spot {
let mut ink = El::new("Ink");
@@ -551,42 +578,7 @@ impl<'a> Ex<'a> {
}
for s in &d.swatches {
let SwatchValue::Gradient { gradient } = &s.value else { continue };
let id = self.sw(&s.name);
let mut g = El::new("Gradient")
.attr("Self", &id)
.attr("Type", if gradient.kind == GradientKind::Radial { "Radial" } else { "Linear" })
.attr("Name", &s.name)
.attr("ColorEditable", "true")
.attr("ColorRemovable", "true")
.attr("Visible", "true")
.attr("SwatchCreatorID", "7937");
for (i, stop) in gradient.stops.iter().enumerate() {
// A stop on a swatch names it; a plain color names an equal color swatch or
// an unnamed color.
let named = stop
.swatch
.as_deref()
.filter(|n| d.swatch(n).is_some_and(|w| !matches!(w.value, SwatchValue::Gradient { .. } | SwatchValue::None)));
let cid = match (named, by_value.iter().find(|(c, _)| *c == stop.color)) {
(Some(n), _) => self.sw(n),
(None, Some((_, id))) => id.clone(),
(None, None) => {
let cid = format!("Color/{}", self.fresh());
colors.push(color_el(&cid, "$ID/", "Process", &stop.color, "Normal", true, true, false));
by_value.push((stop.color, cid.clone()));
cid
}
};
let mut e = El::new("GradientStop")
.attr("Self", format!("{}GradientStop{i}", id.replace(['/', ' '], "_")))
.attr("StopColor", cid)
.attr("Location", pct(stop.offset as f64));
if i > 0 {
e.set("Midpoint", pct(gradient.stops[i - 1].midpoint as f64));
}
g.push(e);
}
gradients.push(g);
gradients.push(self.gradient_el(s, gradient, &mut colors, &mut by_value));
}
for c in colors {
root.push(c);
@@ -3301,6 +3293,24 @@ fn layer_el(l: &designcraft_doc::Layer) -> El {
with_props(el, vec![color])
}
/// A `Color` element. `flags` is (editable, removable, visible).
fn color_el(self_id: &str, name: &str, model: &str, c: &Color, over: &str, (editable, removable, visible): (bool, bool, bool)) -> El {
let (space, val) = color_value(c);
El::new("Color")
.attr("Self", self_id)
.attr("Model", model)
.attr("Space", space)
.attr("ColorValue", val)
.attr("ColorOverride", over)
.attr("AlternateSpace", "NoAlternateColor")
.attr("AlternateColorValue", "")
.attr("Name", name)
.attr("ColorEditable", bool_s(editable))
.attr("ColorRemovable", bool_s(removable))
.attr("Visible", bool_s(visible))
.attr("SwatchCreatorID", "7937")
}
/// A text variable definition with its type's settings element.
fn text_variable_el(v: &designcraft_doc::vars::TextVariable) -> El {
use designcraft_doc::vars::{Use, VarKind};
+121 -96
View File
@@ -919,6 +919,45 @@ impl<'r> Importer<'r> {
Some((c, ty))
}
/// Custom stroke styles.
fn read_stroke_styles(&mut self, top: &[El]) {
for e in top.iter().filter(|e| matches!(e.local(), "StripedStrokeStyle" | "DashedStrokeStyle" | "DottedStrokeStyle")) {
let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) else { continue };
let kind = match e.local() {
"StripedStrokeStyle" => designcraft_doc::StrokeType::Stripes {
bands: nums(e.get("StripeArray").unwrap_or(""))
.as_chunks::<2>()
.0
.iter()
.map(|c| (c[0] / 100.0, (c[1] - c[0]) / 100.0))
.collect(),
},
"DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: nums(e.get("DashArray").unwrap_or("")) },
_ => designcraft_doc::StrokeType::Dotted,
};
let _ = id;
self.stroke_styles.push(designcraft_doc::StrokeStyleDef { name: name.to_string(), kind });
}
}
/// Ink Manager settings: inks converted to process and ink aliases.
fn read_inks(&mut self, top: &[El]) {
for e in top.iter().filter(|e| e.local() == "Ink") {
let Some(name) = e
.get("Name")
.filter(|n| !n.starts_with("$ID/") && !["Process Cyan", "Process Magenta", "Process Yellow", "Process Black"].contains(n))
else {
continue;
};
if e.get("ConvertToProcess") == Some("true") {
self.inks.to_process.push(name.to_string());
}
if let Some(a) = e.get("AliasInkName").filter(|a| !a.is_empty() && *a != name && !a.starts_with("$ID/")) {
self.inks.aliases.push((name.to_string(), a.to_string()));
}
}
}
fn graphics(&mut self, top: &[El]) {
let mut order: Vec<(String, Swatch)> = Vec::new();
// Colours.
@@ -962,39 +1001,8 @@ impl<'r> Importer<'r> {
Swatch { name, value: SwatchValue::Tint { base, tint: (t / 100.0) as f32 }, locked: false, named: true, hidden: false },
));
}
// Custom stroke styles.
for e in top.iter().filter(|e| matches!(e.local(), "StripedStrokeStyle" | "DashedStrokeStyle" | "DottedStrokeStyle")) {
let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) else { continue };
let kind = match e.local() {
"StripedStrokeStyle" => designcraft_doc::StrokeType::Stripes {
bands: nums(e.get("StripeArray").unwrap_or(""))
.as_chunks::<2>()
.0
.iter()
.map(|c| (c[0] / 100.0, (c[1] - c[0]) / 100.0))
.collect(),
},
"DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: nums(e.get("DashArray").unwrap_or("")) },
_ => designcraft_doc::StrokeType::Dotted,
};
let _ = id;
self.stroke_styles.push(designcraft_doc::StrokeStyleDef { name: name.to_string(), kind });
}
// Ink Manager.
for e in top.iter().filter(|e| e.local() == "Ink") {
let Some(name) = e
.get("Name")
.filter(|n| !n.starts_with("$ID/") && !["Process Cyan", "Process Magenta", "Process Yellow", "Process Black"].contains(n))
else {
continue;
};
if e.get("ConvertToProcess") == Some("true") {
self.inks.to_process.push(name.to_string());
}
if let Some(a) = e.get("AliasInkName").filter(|a| !a.is_empty() && *a != name && !a.starts_with("$ID/")) {
self.inks.aliases.push((name.to_string(), a.to_string()));
}
}
self.read_stroke_styles(top);
self.read_inks(top);
for e in top.iter().filter(|e| e.local() == "Gradient") {
let Some(id) = e.get("Self") else { continue };
let mut stops: Vec<GradientStop> = Vec::new();
@@ -2265,43 +2273,7 @@ impl<'r> Importer<'r> {
cell.style = if cell_style == designcraft_doc::NO_CELL_STYLE { String::new() } else { cell_style };
let default_para = cell.text.paras.first().cloned().unwrap_or_default();
cell.text = self.story_with_para(StoryId(0), ce, default_para);
if let Some(fc) = ce.get("FillColor") {
cell.fill = self.swatch_ref(fc);
}
if let Some(tint) = tint(ce.num("FillTint")) {
cell.fill_tint = tint;
}
for (i, k) in ["TopInset", "LeftInset", "BottomInset", "RightInset"].iter().enumerate() {
if let Some(v) = ce.num(k) {
cell.insets[i] = v.max(0.0);
}
}
if let Some(vj) = ce.get("VerticalJustification") {
cell.vj = names::vj_in(vj);
}
if let Some(fb) = ce.get("FirstBaselineOffset") {
cell.first_baseline = names::first_baseline_in(fb);
}
if let Some(min) = ce.num("MinimumFirstBaselineOffset").filter(|v| v.is_finite()) {
cell.first_baseline_min = min.clamp(0.0, 8640.0);
}
cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation);
if cell.rotation != 0.0 {
self.rotated_cells += 1;
}
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
let attrs = self.cell_stroke_attrs(ce, side);
attrs.apply_to(&mut cell.strokes[i]);
cell.stroke_defined[i] |= !attrs.is_empty();
let priority = ce.prop(&format!("{side}StrokePriority")).and_then(|v| v.trim().parse::<i32>().ok()).unwrap_or(0);
cell.stroke_priorities[i] = priority;
// Preserve style-defined edges and #102's positive-priority local overrides.
cell.border_overrides[i] |= priority > 0;
// No table or table style border is given anywhere, so the built-in 1 pt default must not
// draw over an outer edge the cell explicitly hides (weight 0 or the None swatch), as InDesign draws nothing.
let hidden = attrs.weight == Some(0.0) || attrs.color.as_deref() == Some(swatch::NONE);
cell.border_overrides[i] |= border_unset && hidden;
}
self.read_cell_attrs(&mut cell, ce, border_unset);
if let Some(slot) = t.cell_mut(r, c) {
*slot = cell;
}
@@ -2315,6 +2287,47 @@ impl<'r> Importer<'r> {
t
}
/// A cell's local fill, insets, alignment, rotation and edge strokes.
fn read_cell_attrs(&mut self, cell: &mut designcraft_doc::Cell, ce: &El, border_unset: bool) {
if let Some(fc) = ce.get("FillColor") {
cell.fill = self.swatch_ref(fc);
}
if let Some(tint) = tint(ce.num("FillTint")) {
cell.fill_tint = tint;
}
for (i, k) in ["TopInset", "LeftInset", "BottomInset", "RightInset"].iter().enumerate() {
if let Some(v) = ce.num(k) {
cell.insets[i] = v.max(0.0);
}
}
if let Some(vj) = ce.get("VerticalJustification") {
cell.vj = names::vj_in(vj);
}
if let Some(fb) = ce.get("FirstBaselineOffset") {
cell.first_baseline = names::first_baseline_in(fb);
}
if let Some(min) = ce.num("MinimumFirstBaselineOffset").filter(|v| v.is_finite()) {
cell.first_baseline_min = min.clamp(0.0, 8640.0);
}
cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation);
if cell.rotation != 0.0 {
self.rotated_cells += 1;
}
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
let attrs = self.cell_stroke_attrs(ce, side);
attrs.apply_to(&mut cell.strokes[i]);
cell.stroke_defined[i] |= !attrs.is_empty();
let priority = ce.prop(&format!("{side}StrokePriority")).and_then(|v| v.trim().parse::<i32>().ok()).unwrap_or(0);
cell.stroke_priorities[i] = priority;
// Preserve style-defined edges and #102's positive-priority local overrides.
cell.border_overrides[i] |= priority > 0;
// No table or table style border is given anywhere, so the built-in 1 pt default must not
// draw over an outer edge the cell explicitly hides (weight 0 or the None swatch), as InDesign draws nothing.
let hidden = attrs.weight == Some(0.0) || attrs.color.as_deref() == Some(swatch::NONE);
cell.border_overrides[i] |= border_unset && hidden;
}
}
/// The format of the text in a `CharacterStyleRange`: its style, and its local attributes over
/// the enclosing paragraph range's, inside a tracked change if `outer` (the enclosing format) is.
fn range_format(&mut self, c: &El, pchars: &CharAttrs, outer: &CharFormat) -> CharFormat {
@@ -2546,10 +2559,9 @@ impl<'r> Importer<'r> {
.collect()
}
fn spread(&mut self, e: &El, id: SpreadId, master: bool) -> Spread {
let facing = self.settings.facing_pages;
// Page rectangles in IDML spread coordinates.
let mut pages: Vec<(Rect, &El)> = Vec::new();
/// The page rectangles of a spread element, in IDML spread coordinates.
fn page_rects<'a>(&self, e: &'a El) -> Vec<(Rect, &'a El)> {
let mut pages: Vec<(Rect, &'a El)> = Vec::new();
for p in e.find_all("Page") {
let gb = nums(p.get("GeometricBounds").unwrap_or(""));
let (t, l, bt, r) =
@@ -2559,6 +2571,40 @@ impl<'r> Importer<'r> {
let c = xf * Point::new(r, bt);
pages.push((Rect::from_points(a, c), p));
}
pages
}
/// A page's margins and columns, and whether they are local (differ from its parent page's).
fn page_margins(&self, p: &El, r: &Rect, master: bool, parent: Option<SpreadId>, side: PageSide) -> (Margins, Columns, bool) {
// A document page's margins are local when they differ from its parent page's; a
// page without its own takes the parent page's.
let from_parent = if master {
None
} else {
parent
.and_then(|id| self.parents.iter().find(|sp| sp.id == id))
.and_then(|sp| sp.pages.get(sp.parent_page_for_side(side)?))
.map(|q| (q.margins, q.columns.clone()))
};
let (margins, columns) = match (p.find("MarginPreference"), &from_parent) {
(Some(m), _) => {
let (margins, columns) = margins_of(m);
(margins, fit_columns(columns, &margins, r.width()))
}
(None, Some(own)) => own.clone(),
(None, None) => {
let (margins, columns) = self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default()));
(margins, fit_columns(columns, &margins, r.width()))
}
};
let local_margins = from_parent.is_some_and(|(m, c)| m != margins || c != columns);
(margins, columns, local_margins)
}
fn spread(&mut self, e: &El, id: SpreadId, master: bool) -> Spread {
let facing = self.settings.facing_pages;
// Page rectangles in IDML spread coordinates.
let pages = self.page_rects(e);
let min_x = pages.iter().map(|(r, _)| r.x0).fold(f64::INFINITY, f64::min);
let min_y = pages.iter().map(|(r, _)| r.y0).fold(f64::INFINITY, f64::min);
let (min_x, min_y) = if min_x.is_finite() { (min_x, min_y) } else { (0.0, 0.0) };
@@ -2585,28 +2631,7 @@ impl<'r> Importer<'r> {
PageSide::Right
};
let parent = p.get("AppliedMaster").and_then(|m| self.parent_ids.get(m)).copied();
// A document page's margins are local when they differ from its parent page's; a
// page without its own takes the parent page's.
let from_parent = if master {
None
} else {
parent
.and_then(|id| self.parents.iter().find(|sp| sp.id == id))
.and_then(|sp| sp.pages.get(sp.parent_page_for_side(side)?))
.map(|q| (q.margins, q.columns.clone()))
};
let (margins, columns) = match (p.find("MarginPreference"), &from_parent) {
(Some(m), _) => {
let (margins, columns) = margins_of(m);
(margins, fit_columns(columns, &margins, r.width()))
}
(None, Some(own)) => own.clone(),
(None, None) => {
let (margins, columns) = self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default()));
(margins, fit_columns(columns, &margins, r.width()))
}
};
let local_margins = from_parent.is_some_and(|(m, c)| m != margins || c != columns);
let (margins, columns, local_margins) = self.page_margins(p, r, master, parent, side);
out_pages.push(Page {
id: pid,
width: r.width(),
+1 -1
View File
@@ -88,7 +88,7 @@ impl El {
if let Some(v) = self.get(k) {
return Some(v.to_string());
}
self.prop_el(k).map(|e| e.text_content())
self.prop_el(k).map(El::text_content)
}
pub fn prop_el(&self, k: &str) -> Option<&El> {
self.find("Properties").and_then(|p| p.find(k))
+1 -1
View File
@@ -8,7 +8,7 @@ use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, SpreadRef, TextDir
use designcraft_geom::Rect;
use crate::tests::extract_text;
use crate::*;
use crate::{PdfOptions, export_pdf};
const HEBREW: &str = "DC Test PDF Hebrew";
const ARABIC: &str = "DC Test PDF Arabic";
+29 -24
View File
@@ -234,6 +234,34 @@ impl Exporter<'_> {
}
}
/// One line's glyphs as tagged runs; a glyph that is turned or slanted gets a transform of its own.
fn line_glyphs(&mut self, s: &mut Surface, cs: &ComposedStory, ft: &FrameText, l: &designcraft_compose::Line, story: &str) {
let gs = &l.glyphs;
let mut i = 0;
while i < gs.len() {
let g = &gs[i];
if !g.visible || g.sx <= 0.0 || g.sy <= 0.0 {
i += 1;
continue;
}
// Vertical type: an upright glyph turns back a quarter about its em box centre.
// Shatai slants each glyph about its own em box centre first.
let turn = if ft.vertical { g.vertical_xf(l.baseline) } else { None };
let shatai = cs.styles.get(g.style as usize).and_then(|st| st.shatai_xf(g, l.baseline));
if turn.is_some() || shatai.is_some() {
let m = turn.unwrap_or(Affine::IDENTITY) * shatai.unwrap_or(Affine::IDENTITY);
s.push_transform(&crate::export::tf(m));
self.tagged_run(s, cs, &gs[i..i + 1], l.baseline, story);
s.pop();
i += 1;
continue;
}
let j = run_end(ft, gs, i);
self.tagged_run(s, cs, &gs[i..j], l.baseline, story);
i = j;
}
}
pub(crate) fn frame_text(&mut self, s: &mut Surface, cs: &ComposedStory, ft: &FrameText, story: &str) {
// Paragraph shading and rules under the text.
self.as_artifact(s, |me, s| {
@@ -261,30 +289,7 @@ impl Exporter<'_> {
});
}
for l in &ft.lines {
let gs = &l.glyphs;
let mut i = 0;
while i < gs.len() {
let g = &gs[i];
if !g.visible || g.sx <= 0.0 || g.sy <= 0.0 {
i += 1;
continue;
}
// Vertical type: an upright glyph turns back a quarter about its em box centre.
// Shatai slants each glyph about its own em box centre first.
let turn = if ft.vertical { g.vertical_xf(l.baseline) } else { None };
let shatai = cs.styles.get(g.style as usize).and_then(|st| st.shatai_xf(g, l.baseline));
if turn.is_some() || shatai.is_some() {
let m = turn.unwrap_or(Affine::IDENTITY) * shatai.unwrap_or(Affine::IDENTITY);
s.push_transform(&crate::export::tf(m));
self.tagged_run(s, cs, &gs[i..i + 1], l.baseline, story);
s.pop();
i += 1;
continue;
}
let j = run_end(ft, gs, i);
self.tagged_run(s, cs, &gs[i..j], l.baseline, story);
i = j;
}
self.line_glyphs(s, cs, ft, l, story);
}
self.as_artifact(s, |me, s| {
for (rule, r) in text_rules(cs, ft, false) {
+43 -39
View File
@@ -555,6 +555,48 @@ impl Renderer {
self.drawing_items.pop();
}
/// A group: children carry their own transforms relative to the group.
fn draw_group(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, xf: Affine, page: ShownOn) {
// Isolate Blending / Knockout: the group is its own layer (blend modes stop at it).
let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal || it.isolate || it.knockout;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None);
}
// Gradient feather: the group's objects drawn together, then faded as one.
let feather = it.effects.gradient_feather.on && f.opts.quality != DisplayQuality::Fast;
if feather {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, None, None, None);
}
if it.knockout {
// Knockout: each object first clears its area of those behind it, so it composites
// with the group's backdrop rather than with its siblings.
for c in it.shown_children() {
if !c.hidden {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None);
ctx.set_transform(f.view * xf * c.xf);
ctx.set_paint(peniko::Color::BLACK);
ctx.fill_path(&c.path.to_bezpath());
ctx.pop_layer();
}
self.draw_item(ctx, f, c, xf, page);
}
} else {
for c in it.shown_children() {
self.draw_item(ctx, f, c, xf, page);
}
}
if feather {
fx::gradient_feather_mask(ctx, f, it, xf);
ctx.pop_layer();
}
if layered {
ctx.pop_layer();
}
}
fn draw_item_inner(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page: ShownOn) {
if it.hidden || f.opts.hidden.contains(&it.id) || f.opts.printing_only && it.nonprinting {
return;
@@ -564,45 +606,7 @@ impl Renderer {
// A frame with items pasted into it draws like any other object (draw_body: fill, the items
// clipped to the frame, stroke), so its effects apply to all of it.
if !it.children().is_empty() && !it.has_nested_items() {
// Groups: children carry their own transforms relative to the group.
// Isolate Blending / Knockout: the group is its own layer (blend modes stop at it).
let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal || it.isolate || it.knockout;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None);
}
// Gradient feather: the group's objects drawn together, then faded as one.
let feather = it.effects.gradient_feather.on && f.opts.quality != DisplayQuality::Fast;
if feather {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, None, None, None);
}
if it.knockout {
// Knockout: each object first clears its area of those behind it, so it composites
// with the group's backdrop rather than with its siblings.
for c in it.shown_children() {
if !c.hidden {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None);
ctx.set_transform(f.view * xf * c.xf);
ctx.set_paint(peniko::Color::BLACK);
ctx.fill_path(&c.path.to_bezpath());
ctx.pop_layer();
}
self.draw_item(ctx, f, c, xf, page);
}
} else {
for c in it.shown_children() {
self.draw_item(ctx, f, c, xf, page);
}
}
if feather {
fx::gradient_feather_mask(ctx, f, it, xf);
ctx.pop_layer();
}
if layered {
ctx.pop_layer();
}
self.draw_group(ctx, f, it, xf, page);
return;
}
// Composed text stays inside its frame apart from glyph overhang (italics, swashes, big
+32 -32
View File
@@ -91,6 +91,34 @@ fn general(f: f64) -> String {
}
}
/// The first and last column used on the header row, if that row has any cells.
fn header_span(cells: &[(usize, usize, String)], header_row: usize) -> Option<(usize, usize)> {
cells.iter().filter(|(r, _, _)| *r == header_row).fold(None, |span, (_, c, _)| match span {
None => Some((*c, *c)),
Some((lo, hi)) => Some((lo.min(*c), hi.max(*c))),
})
}
/// One row's values laid into `width` columns starting at column `min_c`; values outside that
/// span come back separately (only the non-empty ones), so the caller can warn.
fn place_row(cols: &[(usize, String)], min_c: usize, width: usize) -> (Vec<String>, Vec<String>) {
let mut row = vec![String::new(); width];
let mut extra: Vec<String> = Vec::new();
for (c, v) in cols {
let at = c.checked_sub(min_c).filter(|at| *at < width);
match at {
Some(at) => {
if let Some(cell) = row.get_mut(at) {
*cell = v.clone();
}
}
None if !v.is_empty() => extra.push(v.clone()),
None => {}
}
}
(row, extra)
}
/// One worksheet as a grid of cached cell text, empty cells kept, for data merge.
/// The first used row is the header. Later rows that are entirely empty are left out.
/// Cells past the header are kept so the caller can warn. Over 200 columns or 100,000
@@ -117,19 +145,9 @@ pub fn records(bytes: &[u8], sheet: Option<&str>) -> Result<Vec<Vec<String>>, Im
return Err(ImportError::Corrupt("the worksheet is empty".into()));
}
let header_row = cells.iter().map(|c| c.0).min().unwrap_or(0);
let mut min_c = usize::MAX;
let mut max_c = 0usize;
let mut header_used = false;
for (r, c, _) in &cells {
if *r == header_row {
header_used = true;
min_c = min_c.min(*c);
max_c = max_c.max(*c);
}
}
if !header_used || min_c == usize::MAX {
let Some((min_c, max_c)) = header_span(&cells, header_row) else {
return Err(ImportError::Corrupt("the worksheet has no header".into()));
}
};
let width = max_c.saturating_sub(min_c).saturating_add(1);
if width > crate::DATA_MERGE_MAX_COLS {
return Err(ImportError::Corrupt(format!("the sheet has {width} columns; the limit is {}", crate::DATA_MERGE_MAX_COLS)));
@@ -154,26 +172,8 @@ pub fn records(bytes: &[u8], sheet: Option<&str>) -> Result<Vec<Vec<String>>, Im
}
let mut out = Vec::with_capacity(data_rows.saturating_add(1));
for (r, cols) in &by_row {
let mut row = vec![String::new(); width];
let mut extra: Vec<String> = Vec::new();
for (c, v) in cols {
if *c < min_c {
// Outside the header span. Keep one non-empty value so the caller can warn.
if !v.is_empty() {
extra.push(v.clone());
}
continue;
}
let at = c - min_c;
if at < width {
if let Some(cell) = row.get_mut(at) {
*cell = v.clone();
}
} else if !v.is_empty() {
extra.push(v.clone());
}
}
if *r != header_row && row.iter().all(|c| c.is_empty()) && extra.is_empty() {
let (mut row, extra) = place_row(cols, min_c, width);
if *r != header_row && row.iter().all(String::is_empty) && extra.is_empty() {
continue;
}
row.extend(extra);
+3
View File
@@ -44,3 +44,6 @@ designcraft-fonts = { workspace = true, features = ["testing"] }
[build-dependencies]
serde_json = { workspace = true }
toml = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
+54 -41
View File
@@ -2451,6 +2451,26 @@ fn scrollbar_rects(outer: Rect, screen: Rect, content: Rect, vertical: bool) ->
}
}
/// How far a scrollbar interaction pans the view along its axis: a thumb drag follows the pointer,
/// a click on the track beside the thumb pages by one view.
fn scrollbar_move(resp: &egui::Response, grabbed: bool, vertical: bool, thumb: Rect, page: f32, k: f32) -> f32 {
if grabbed {
let d = resp.drag_delta();
return -(if vertical { d.y } else { d.x }) * k;
}
if resp.clicked()
&& let Some(p) = resp.interact_pointer_pos()
{
let (p, a, b) = if vertical { (p.y, thumb.min.y, thumb.max.y) } else { (p.x, thumb.min.x, thumb.max.x) };
if p < a {
return page;
} else if p > b {
return -page;
}
}
0.0
}
/// The view's scrollbars, on the right and bottom edges of `outer`: they span the pasteboard;
/// dragging a thumb pans, clicking the track pages by one view.
fn scrollbars(app: &mut DesignApp, ui: &mut egui::Ui, outer: Rect, screen: Rect, rect: Rect, t: &Tokens) {
@@ -2476,21 +2496,8 @@ fn scrollbars(app: &mut DesignApp, ui: &mut egui::Ui, outer: Rect, screen: Rect,
if !resp.dragged() {
ui.data_mut(|d| d.remove::<bool>(grab_id));
}
let along = |p: egui::Vec2| if vertical { p.y } else { p.x };
let page = along(screen.size());
let mut moved = 0.0;
if grabbed {
moved = -along(resp.drag_delta()) * k;
} else if resp.clicked()
&& let Some(p) = resp.interact_pointer_pos()
{
let (p, a, b) = if vertical { (p.y, thumb.min.y, thumb.max.y) } else { (p.x, thumb.min.x, thumb.max.x) };
if p < a {
moved = page;
} else if p > b {
moved = -page;
}
}
let page = if vertical { screen.size().y } else { screen.size().x };
let moved = scrollbar_move(&resp, grabbed, vertical, thumb, page, k);
if moved != 0.0 && moved.is_finite() {
pan(app, &xf, if vertical { vec2(0.0, moved) } else { vec2(moved, 0.0) });
}
@@ -3686,6 +3693,37 @@ fn guide_at(app: &DesignApp, xf: &Xf, p: Pos2) -> Option<(designcraft_doc::Sprea
None
}
/// A primary press at `o` on a ruler starts a new guide; on a guide it selects and grabs it.
fn begin_guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect, xf: &Xf, o: Pos2) -> Option<GuideDrag> {
let full = resp.rect;
if full.contains(o) && !rect.contains(o) {
// In a ruler (not the corner).
let orientation = if o.y < rect.min.y && o.x >= rect.min.x {
Some(designcraft_doc::Orientation::Horizontal)
} else if o.x < rect.min.x && o.y >= rect.min.y {
Some(designcraft_doc::Orientation::Vertical)
} else {
None
};
if let Some(orientation) = orientation {
return Some(GuideDrag { orientation, from: None, at: Some(o), mods: egui::Modifiers::NONE });
}
} else if rect.contains(o)
&& matches!(app.session.tool_id(), "selection" | "directSelection")
&& let Some((r, pi, gi, orientation)) = guide_at(app, xf, o)
{
let shift = ui.input(|i| i.modifiers.shift);
// A press on a guide selects it (Shift: on release, as a click adds or removes it).
let selected = app.session.active().is_some_and(|st| st.selection.guides.contains(&GuideSel { spread: r, page: pi, index: gi }));
if !shift && !selected {
let _ = app.run("guide.select", json!({"spread": r, "page": pi, "index": gi}));
}
ui.data_mut(|d| d.insert_temp(egui::Id::unique(GUIDE_PRESS), GuidePress { origin: o, shift }));
return Some(GuideDrag { orientation, from: Some((r, pi, gi)), at: Some(o), mods: egui::Modifiers::NONE });
}
None
}
/// Ruler guides: drag one out of a ruler, drag an existing one (Selection tools) to move it, drop
/// it back on a ruler to delete it. A press near an unlocked guide takes it even over an object:
/// the guide is selected and the object is not. Returns true from the press to the release, so
@@ -3697,32 +3735,7 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec
let mut drag: Option<GuideDrag> = ui.data(|d| d.get_temp(id));
if drag.is_none() && pressed && resp.hovered() && app.ui.guides && !app.ui.guides_locked {
let Some(o) = origin else { return false };
let full = resp.rect;
if full.contains(o) && !rect.contains(o) {
// In a ruler (not the corner).
let orientation = if o.y < rect.min.y && o.x >= rect.min.x {
Some(designcraft_doc::Orientation::Horizontal)
} else if o.x < rect.min.x && o.y >= rect.min.y {
Some(designcraft_doc::Orientation::Vertical)
} else {
None
};
if let Some(orientation) = orientation {
drag = Some(GuideDrag { orientation, from: None, at: Some(o), mods: egui::Modifiers::NONE });
}
} else if rect.contains(o)
&& matches!(app.session.tool_id(), "selection" | "directSelection")
&& let Some((r, pi, gi, orientation)) = guide_at(app, xf, o)
{
let shift = ui.input(|i| i.modifiers.shift);
// A press on a guide selects it (Shift: on release, as a click adds or removes it).
let selected = app.session.active().is_some_and(|st| st.selection.guides.contains(&GuideSel { spread: r, page: pi, index: gi }));
if !shift && !selected {
let _ = app.run("guide.select", json!({"spread": r, "page": pi, "index": gi}));
}
ui.data_mut(|d| d.insert_temp(egui::Id::unique(GUIDE_PRESS), GuidePress { origin: o, shift }));
drag = Some(GuideDrag { orientation, from: Some((r, pi, gi)), at: Some(o), mods: egui::Modifiers::NONE });
}
drag = begin_guide_drag(app, ui, resp, rect, xf, o);
}
let Some(mut g) = drag else { return false };
if let Some(p) = latest {
+4 -1
View File
@@ -4,7 +4,10 @@
use egui::{Event, Modifiers, PointerButton};
use egui_kittest::Harness;
use super::*;
use super::{
CanvasLayout, DesignApp, GuideDrag, GuidePress, Mods, Overlay, Point, Pos2, RULER, SpreadRef, ToolKey, Xf, guide_drag_delta, guide_spot, json,
moving_guides, pos2, vec2,
};
fn harness(app: DesignApp) -> Harness<'static, DesignApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
+51 -35
View File
@@ -33,6 +33,7 @@ pub mod story_editor;
pub mod taskbar;
pub mod theme;
pub mod toolbar;
pub mod updates_ui;
pub mod widgets;
use std::collections::HashMap;
@@ -836,6 +837,7 @@ pub struct DesignApp {
pub cursors: cursors::Images,
/// A panel's live preview (a slider being dragged), committed when it is let go.
live_preview: LivePreview,
pub(crate) updates_rt: updates_ui::Runtime,
}
/// The preview a panel opened with `edit.preview`: it stays open while a panel holds it each frame.
@@ -935,6 +937,7 @@ impl DesignApp {
quitting: None,
cursors: cursors::Images::default(),
live_preview: LivePreview::default(),
updates_rt: updates_ui::Runtime::default(),
}
}
@@ -1365,6 +1368,50 @@ impl DesignApp {
self.ui.flyout = None;
}
/// First frame only: load the saved colour profiles and make the saved Color Settings active.
fn apply_saved_color_settings(&mut self) {
if self.color_applied {
return;
}
self.color_applied = true;
for path in self.ui.color_profiles.clone() {
if let Err(e) = self.session.execute("color.loadProfile", &json!({"path": path})) {
log::warn!("Color Settings: {e}");
self.status_error("Color Settings", e);
}
}
if let Some(cs) = self.ui.color_settings.clone() {
let known = cs.with_known_profiles();
for (saved, used) in [(&cs.rgb, &known.rgb), (&cs.cmyk, &known.cmyk)] {
if saved != used {
let e = format!("saved profile {saved} is not available; using {used}");
log::warn!("Color Settings: {e}");
self.status_error("Color Settings", e);
}
}
let _ = designcraft_color::cms::set_active(&known);
}
}
/// Install the UI fonts on the first frame, and again when the language or the system fonts change.
fn update_fonts(&mut self, ctx: &egui::Context) {
if !self.styled {
// The UI scale is a preference (applied above); egui's own Cmd+Plus/Minus/0 would rescale the
// whole window instead of the canvas, only to be reset next frame.
ctx.options_mut(|o| o.zoom_with_keyboard = false);
self.fonts_system = theme::install_fonts(ctx, &self.ui.language);
self.fonts_lang = self.ui.language.clone();
self.styled = true;
self.restyle = true;
} else {
if self.fonts_lang != self.ui.language || (!self.fonts_system && theme::system_fonts_ready()) {
self.fonts_system = theme::install_fonts(ctx, &self.ui.language);
self.fonts_lang = self.ui.language.clone();
}
self.fonts_ready = true;
}
}
/// Queue and deadline work, run once per app callback.
///
/// `logic_tick` runs on every call (a first pass, a later discarded pass, or a hidden
@@ -1384,6 +1431,7 @@ impl DesignApp {
fn logic_tick(&mut self, ctx: &egui::Context) {
self.update_screenshot_epoch(ctx);
self.drain_control(ctx);
updates_ui::tick(self, ctx);
if self.fonts_ready && !self.host_order_pending() {
self.drain_inbox();
self.open_os_documents();
@@ -1414,45 +1462,12 @@ impl DesignApp {
self.egui_ctx = Some(ctx.clone());
}
self.window_maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
if !self.color_applied {
self.color_applied = true;
for path in self.ui.color_profiles.clone() {
if let Err(e) = self.session.execute("color.loadProfile", &json!({"path": path})) {
log::warn!("Color Settings: {e}");
self.status_error("Color Settings", e);
}
}
if let Some(cs) = self.ui.color_settings.clone() {
let known = cs.with_known_profiles();
for (saved, used) in [(&cs.rgb, &known.rgb), (&cs.cmyk, &known.cmyk)] {
if saved != used {
let e = format!("saved profile {saved} is not available; using {used}");
log::warn!("Color Settings: {e}");
self.status_error("Color Settings", e);
}
}
let _ = designcraft_color::cms::set_active(&known);
}
}
self.apply_saved_color_settings();
let scale = self.ui.ui_scale.clamp(0.5, 3.0);
if (ctx.zoom_factor() - scale).abs() > 1e-3 {
ctx.set_zoom_factor(scale);
}
if !self.styled {
// The UI scale is a preference (applied above); egui's own Cmd+Plus/Minus/0 would rescale the
// whole window instead of the canvas, only to be reset next frame.
ctx.options_mut(|o| o.zoom_with_keyboard = false);
self.fonts_system = theme::install_fonts(ctx, &self.ui.language);
self.fonts_lang = self.ui.language.clone();
self.styled = true;
self.restyle = true;
} else {
if self.fonts_lang != self.ui.language || (!self.fonts_system && theme::system_fonts_ready()) {
self.fonts_system = theme::install_fonts(ctx, &self.ui.language);
self.fonts_lang = self.ui.language.clone();
}
self.fonts_ready = true;
}
self.update_fonts(ctx);
if !self.fonts_system {
// The font scan runs in the background: look again shortly.
ctx.request_repaint_after(std::time::Duration::from_millis(250));
@@ -2149,6 +2164,7 @@ impl DesignApp {
story_editor::show(self, &ctx);
dialogs::show(self, &ctx);
about::show(self, &ctx);
updates_ui::show(self, &ctx);
menus::palette(self, &ctx);
// The IME is observed once, here: after the canvas, the floating panels, the story editor,
// dialogs and Quick Apply have all applied this pass's commands and focus changes.
+4
View File
@@ -348,6 +348,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
None,
"{open?: true, tab?: \"about\"|\"contributors\"|\"models\"} — the About window: splash with community links, contributor and model credits (open: false closes it)",
),
("help.checkForUpdates", "Check for Updates…", None, "{} — checks for newer releases and offers to update"),
("window.toolsDoubleColumn", "Tools: Double Column", None, "{}"),
(
"window.workspace",
@@ -990,6 +991,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"-",
"cmd:file.newSample",
"-",
"ui:help.checkForUpdates",
"ui:help.about",
],
),
@@ -1911,6 +1913,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
app.ui.about = p.get("open").and_then(Value::as_bool).unwrap_or(true);
Ok(json!(app.ui.about))
}
"help.checkForUpdates" => crate::updates_ui::check(app),
"help.app" => match p.get("app").and_then(Value::as_str) {
Some(slug) if !slug.is_empty() && slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') => {
let url = format!("{}/apps/{slug}", designcraft_engine::links::WEBSITE);
@@ -2418,6 +2421,7 @@ pub enum AppMenuItem {
/// The macOS application menu. Settings… is `app.preferences`, which `native_menu_tree` leaves out of Edit.
pub const MAC_APP_MENU: &[AppMenuItem] = &[
AppMenuItem::Cmd { id: "dc-about", label: "About DesignCraft", command: "help.about" },
AppMenuItem::Cmd { id: "dc-updates", label: "Check for Updates…", command: "help.checkForUpdates" },
AppMenuItem::Cmd { id: "dc-discord", label: "Join the ArtCraft Discord…", command: "help.discord" },
AppMenuItem::Sep,
AppMenuItem::Cmd { id: "dc-settings", label: "Settings…", command: "app.preferences" },
+5 -1
View File
@@ -1,4 +1,8 @@
use super::*;
use super::{
CANVAS, Column, DOCK_LABELS, DesignApp, Floating, ICON_GROUPS, Layout, Node, PanelContent, Placed, Side, Value, area_id, cancel_gesture,
close_visible, default_layout, dock_columns, hit_id, icon_column, in_iconic_column, json, layout_of, modal_open, open_window_drops, panel_menu,
set_iconic, showing, tab_group, tab_id, valid, window_groups,
};
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
+45 -34
View File
@@ -211,6 +211,35 @@ pub(crate) fn preset_name(w: f64, h: f64) -> &'static str {
PAGE_PRESETS.iter().find(|p| (p.1 - a).abs() < 0.6 && (p.2 - b).abs() < 0.6).map(|p| p.0).unwrap_or("Custom")
}
/// The preset dropdown and orientation toggles for one page; the new (width, height) when one changed.
fn page_preset_row(ui: &mut Ui, lang: &str, w: f64, h: f64) -> Option<(f64, f64)> {
let landscape = w > h;
let b = block(ui, FIELD_H);
let mut size = None;
place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| {
let resp = dropdown(ui, crate::i18n::tr(lang, preset_name(w, h)), 93.0);
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(140.0);
for p in PAGE_PRESETS {
if ui.selectable_label(preset_name(w, h) == p.0, crate::i18n::tr(lang, p.0)).clicked() {
size = Some(if landscape { (p.2, p.1) } else { (p.1, p.2) });
ui.close();
}
}
});
});
place(ui, sub(b, 126.0, 0.0, 52.0, FIELD_H), |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if icon_toggle(ui, "orient-portrait", !landscape, crate::i18n::tr(lang, "Portrait")).clicked() && landscape {
size = Some((h, w));
}
if icon_toggle(ui, "orient-landscape", landscape, crate::i18n::tr(lang, "Landscape")).clicked() && !landscape {
size = Some((h, w));
}
});
size
}
/// Properties with the Page tool (Properties › Edit Page), as InDesign's: a "Page" band with Back,
/// Page Size for this page alone (which page, preset, orientation, W/H), Margins and Columns and
/// Quick Actions. Back returns to the Selection tool and the document's properties.
@@ -257,30 +286,7 @@ fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) {
ui.add_space(3.0);
// Preset and orientation: this page alone.
let (w, h) = (pg.width, pg.height);
let landscape = w > h;
let b = block(ui, FIELD_H);
let mut size = None;
place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| {
let resp = dropdown(ui, crate::i18n::tr(&lang, preset_name(w, h)), 93.0);
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(140.0);
for p in PAGE_PRESETS {
if ui.selectable_label(preset_name(w, h) == p.0, crate::i18n::tr(&lang, p.0)).clicked() {
size = Some(if landscape { (p.2, p.1) } else { (p.1, p.2) });
ui.close();
}
}
});
});
place(ui, sub(b, 126.0, 0.0, 52.0, FIELD_H), |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if icon_toggle(ui, "orient-portrait", !landscape, crate::i18n::tr(&lang, "Portrait")).clicked() && landscape {
size = Some((h, w));
}
if icon_toggle(ui, "orient-landscape", landscape, crate::i18n::tr(&lang, "Landscape")).clicked() && !landscape {
size = Some((h, w));
}
});
let mut size = page_preset_row(ui, &lang, w, h);
ui.add_space(3.0);
let b = block(ui, 2.0 * FIELD_H + 5.0);
for (row, (label, v, key)) in [("W:", w, "width"), ("H:", h, "height")].into_iter().enumerate() {
@@ -4171,21 +4177,16 @@ fn selected_container(app: &DesignApp) -> Option<(designcraft_doc::Item, Vec<des
Some((it, kids))
}
/// The alignment grid: 3 × 3 cells (one column or row of three when a space justification spreads
/// the objects along the main axis); the chosen cell shows the direction's arrow.
fn flex_grid(ui: &mut Ui, r: Rect, f: Option<&designcraft_doc::FlexLayout>) -> Option<u8> {
/// The chosen (column, row) of the Flex Layout alignment grid, and whether a space justification
/// leaves only one axis (`Some(true)` for a row layout).
fn flex_grid_choice(f: Option<&designcraft_doc::FlexLayout>) -> ((usize, usize), Option<bool>) {
use designcraft_doc::{FlexAlign, FlexJustify};
let t = Tokens::get(ui.ctx());
let resp = ui.interact(r, ui.scope_id().with("flex_grid"), Sense::click());
ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
let cell = r.width() / 3.0;
let pos = |a: FlexAlign| match a {
FlexAlign::Start => 0,
FlexAlign::Center => 1,
FlexAlign::End => 2,
};
// The chosen (column, row), and whether a space justification leaves only one axis.
let (chosen, spread_row) = match f {
match f {
Some(f) => {
let main = match f.justify {
FlexJustify::Center => 1,
@@ -4197,7 +4198,17 @@ fn flex_grid(ui: &mut Ui, r: Rect, f: Option<&designcraft_doc::FlexLayout>) -> O
(if f.direction.is_row() { (main, cross) } else { (cross, main) }, f.justify.distributes().then_some(f.direction.is_row()))
}
None => ((0, 0), None),
};
}
}
/// The alignment grid: 3 × 3 cells (one column or row of three when a space justification spreads
/// the objects along the main axis); the chosen cell shows the direction's arrow.
fn flex_grid(ui: &mut Ui, r: Rect, f: Option<&designcraft_doc::FlexLayout>) -> Option<u8> {
let t = Tokens::get(ui.ctx());
let resp = ui.interact(r, ui.scope_id().with("flex_grid"), Sense::click());
ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
let cell = r.width() / 3.0;
let (chosen, spread_row) = flex_grid_choice(f);
let mut picked = None;
for row in 0..3usize {
for col in 0..3usize {
+29 -19
View File
@@ -242,6 +242,33 @@ fn folder_button(ui: &mut egui::Ui, tip: &str) -> egui::Response {
})
}
/// The swatch's chip following the pointer while the row is dragged.
fn paint_drag_chip(ui: &egui::Ui, doc: &designcraft_doc::Document, name: &str, p: egui::Pos2) {
let t = Tokens::get(ui.ctx());
let (c, g) = crate::widgets::swatch_colors(doc, name, 1.0);
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("swatch_drag")));
let chip = egui::Rect::from_min_size(p + vec2(8.0, 8.0), vec2(16.0, 16.0));
crate::widgets::paint_chip(&painter, chip, c, g);
painter.rect_stroke(chip, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
}
/// The type indicator shown at the right of a swatch row: colour mode (spot dot), gradient or tint.
fn swatch_kind_label(lang: &str, value: &SwatchValue) -> String {
match value {
SwatchValue::Color { color, color_type } => {
let m = match color {
designcraft_color::Color::Cmyk { .. } => "CMYK",
designcraft_color::Color::Rgb { .. } => "RGB",
designcraft_color::Color::Gray { .. } => crate::i18n::tr(lang, "Gray"),
};
format!("{}{}", if *color_type == ColorType::Spot { "● " } else { "" }, m)
}
SwatchValue::Gradient { .. } => "Gradient".into(),
SwatchValue::Tint { tint, .. } => format!("{:.0}%", tint * 100.0),
_ => String::new(),
}
}
/// One Swatches panel row: chip, name, type; click applies it to the fill or stroke in front of
/// the proxy (of the frames or their text, see [`super::colors_affect_text`]), the context menu
/// to the stroke, colour groups and delete.
@@ -261,12 +288,7 @@ fn swatch_row(
if resp.dragged()
&& let Some(p) = ui.ctx().pointer_interact_pos()
{
// The swatch's chip follows the pointer.
let (c, g) = crate::widgets::swatch_colors(doc, &sw.name, 1.0);
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("swatch_drag")));
let chip = egui::Rect::from_min_size(p + vec2(8.0, 8.0), vec2(16.0, 16.0));
crate::widgets::paint_chip(&painter, chip, c, g);
painter.rect_stroke(chip, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
paint_drag_chip(ui, doc, &sw.name, p);
}
if fill_cur == Some(sw.name.as_str()) {
ui.painter().rect_filled(row, 0.0, t.row_selected);
@@ -278,19 +300,7 @@ fn swatch_row(
crate::widgets::paint_chip(ui.painter(), chip, c, g);
ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text);
// Type indicators: spot dot / process square, colour mode.
let kind = match &sw.value {
SwatchValue::Color { color, color_type } => {
let m = match color {
designcraft_color::Color::Cmyk { .. } => "CMYK",
designcraft_color::Color::Rgb { .. } => "RGB",
designcraft_color::Color::Gray { .. } => crate::i18n::tr(&app.ui.language, "Gray"),
};
format!("{}{}", if *color_type == ColorType::Spot { "● " } else { "" }, m)
}
SwatchValue::Gradient { .. } => "Gradient".into(),
SwatchValue::Tint { tint, .. } => format!("{:.0}%", tint * 100.0),
_ => String::new(),
};
let kind = swatch_kind_label(&app.ui.language, &sw.value);
if sw.locked {
icons::paint(ui.painter(), egui::Rect::from_min_size(egui::pos2(row.max.x - 64.0, row.min.y + 4.0), vec2(13.0, 13.0)), "lock", t.text_dim);
}
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{CanvasLayout, Color32, DesignApp, Point, Pos2, Rect, View, Xf, json, render_texture, vec2};
fn app_with_document_object() -> DesignApp {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+177
View File
@@ -0,0 +1,177 @@
//! Software updates, background check at startup, and startup prompt modal.
use serde_json::Value;
use crate::DesignApp;
#[cfg(not(target_arch = "wasm32"))]
use craft_update::updater::{Action, Status, Updater};
pub(crate) struct Runtime {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater,
/// The desktop app turns the check at start on; tests and the web build leave it off.
#[cfg(not(target_arch = "wasm32"))]
startup_check: bool,
#[cfg(not(target_arch = "wasm32"))]
check_requested: bool,
/// The context of the last frame, to wake the UI when a check is queued from a menu callback.
#[cfg(not(target_arch = "wasm32"))]
repaint: Option<egui::Context>,
pub(crate) manual_window_open: bool,
pub(crate) status_message: Option<String>,
}
impl Default for Runtime {
fn default() -> Self {
Self {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater::new(&craft_update::suite::DESIGNCRAFT, env!("CARGO_PKG_VERSION")),
#[cfg(not(target_arch = "wasm32"))]
startup_check: false,
#[cfg(not(target_arch = "wasm32"))]
check_requested: false,
#[cfg(not(target_arch = "wasm32"))]
repaint: None,
manual_window_open: false,
status_message: None,
}
}
}
impl DesignApp {
/// Whether the app asks for a newer release when it starts (off until the desktop app turns it on).
#[cfg(not(target_arch = "wasm32"))]
pub fn set_startup_update_check(&mut self, on: bool) {
self.updates_rt.startup_check = on;
}
}
pub(crate) fn check(app: &mut DesignApp) -> Result<Value, String> {
#[cfg(not(target_arch = "wasm32"))]
{
app.updates_rt.check_requested = true;
if let Some(ctx) = &app.updates_rt.repaint {
ctx.request_repaint();
}
Ok(Value::Null)
}
#[cfg(target_arch = "wasm32")]
{
app.updates_rt.manual_window_open = true;
app.updates_rt.status_message = Some("Update check is not supported in the web browser.".into());
Ok(Value::Null)
}
}
pub(crate) fn tick(app: &mut DesignApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
let rt = &mut app.updates_rt;
rt.repaint = Some(ctx.clone());
if std::mem::take(&mut rt.check_requested) {
rt.updater.check_now(ctx);
} else if rt.startup_check {
rt.updater.check_at_start(ctx);
}
rt.updater.tick(ctx);
}
#[cfg(target_arch = "wasm32")]
let _ = (app, ctx);
}
pub(crate) fn show(app: &mut DesignApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
let clean = !app.session.documents().iter().any(|d| d.is_dirty());
match app.updates_rt.updater.show_if(ctx, clean) {
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
Action::None => {}
}
// The window answers Help ▸ Check for Updates, so it follows the updater's status.
let rt = &mut app.updates_rt;
(rt.manual_window_open, rt.status_message) = match rt.updater.status() {
Status::Idle => (false, None),
Status::Checking => (true, Some(crate::i18n::tr(&app.ui.language, "Checking for updates…").to_string())),
Status::UpToDate => (
true,
Some(
crate::i18n::tr(&app.ui.language, "DesignCraft {version} is currently the newest version available.")
.replace("{version}", env!("CARGO_PKG_VERSION")),
),
),
Status::Failed(e) => (true, Some(crate::i18n::tr(&app.ui.language, "Could not check for updates:\n{error}").replace("{error}", e))),
};
}
if app.updates_rt.manual_window_open {
let mut open = true;
let title = crate::i18n::tr(&app.ui.language, "DesignCraft Updates");
let ok_label = crate::i18n::tr(&app.ui.language, "OK");
egui::Window::new(title)
.open(&mut open)
.collapsible(false)
.resizable(false)
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.show(ctx, |ui| {
if let Some(msg) = &app.updates_rt.status_message {
ui.label(msg);
}
ui.add_space(8.0);
if ui.button(ok_label).clicked() {
app.updates_rt.manual_window_open = false;
}
});
if !open {
app.updates_rt.manual_window_open = false;
}
}
// Closing the window dismisses the outcome it showed.
#[cfg(not(target_arch = "wasm32"))]
if !app.updates_rt.manual_window_open {
app.updates_rt.updater.dismiss_status();
}
}
#[cfg(test)]
mod tests {
use super::*;
use designcraft_engine::Session;
#[test]
fn default_runtime_is_idle() {
let rt = Runtime::default();
assert!(!rt.manual_window_open);
#[cfg(not(target_arch = "wasm32"))]
{
assert!(!rt.startup_check);
assert!(!rt.updater.prompt_open());
assert_eq!(rt.updater.status(), &Status::Idle);
}
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn startup_prompt_shows_and_closes() {
let mut app = DesignApp::new(Session::new(), Default::default());
let ctx = egui::Context::default();
let release = craft_update::Release {
tag: "v99.0.0".into(),
version: craft_update::parse_version("99.0.0"),
name: "DesignCraft 99.0.0".into(),
html_url: "https://github.com/storytold/designcraft/releases/tag/v99.0.0".into(),
..Default::default()
};
app.updates_rt.updater.offer(release);
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
show(&mut app, ui.ctx());
});
output.textures_delta.clear();
assert!(app.updates_rt.updater.prompt_open());
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
# Shared PDF backend
DesignCraft pins `craft-pdf`, patched Krilla 0.8.2 and the six Hayro reader crates (with
`hayro-cmap` and `hayro-postscript`) to craft-libs revision `e3b9e239c52c14677be462e94d8920ea243de4f3`. `craft-ui`,
`hayro-cmap` and `hayro-postscript`) to craft-libs revision `495e5971bc024aa96e6ed318cb6e7cd3a0103184`. `craft-ui`,
`craft-print` and the other craft-libs crates are pinned to the same revision. The reader migration is reviewed on top of the separate writer change.
## Writing
+1 -1
View File
@@ -5,7 +5,7 @@ use std::process::Command;
use serde_json::Value;
const REPO: &str = "https://github.com/storytold/craft-libs";
const REV: &str = "e3b9e239c52c14677be462e94d8920ea243de4f3";
const REV: &str = "495e5971bc024aa96e6ed318cb6e7cd3a0103184";
const PACKAGES: &[(&str, &str)] = &[
("craft-pdf", "0.1.0"),
("krilla", "0.8.2"),