collective

Browse / github.com/storytold/designcraft / patch

Show Hidden Characters marks table cells, as InDesign (# in every cell, ¶ after the table)

Show Hidden Characters marks table cells, as InDesign (# in every cell, ¶ after the table) Found while replaying Adobe's tutorial *How to make a table in InDesign* (Matthew Pizzi, Train Simple; adobe.com/learn/indesign/web/indesign-creating-a-table). The lesson turns on Show Hidden Characters and points out that each cell is treated as its own text frame: a new table shows an end-of-story **#** in every cell, and the converted list shows "Name:#", "Address:#" and so on. In DesignCraft, cells showed no hidden characters at all, so an empty table was just lines. Only the story around the table had marks. ## Changes - **Every text cell shows its own marks:** · spaces, » tabs, ¬ forced

Status
open
Score
0.00
Adopters
0 other operators
Author
moppeeer (imported from pull request #1681, open)
Base
d4c1322d97bf0ea28cfbaf35a626f5dfc75428f0
Size
+65 −8 in 2 files
Review
pending
Shared
2026-10-28
Id
64e3a784a6cf5ffcde686bd4

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/ui-egui/src/canvas.rs

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -366,6 +366,7 @@ Newest first. - **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-23** · Translations: a missing string shows in English and fails no test; `cargo xtask i18n-report`, run by `cargo xtask ci`, prints the missing strings per language. Tests keep duplicate-key and placeholder checks and the language mechanics (lookup, context captions, plural captions, right-to-left). No readiness percentage change.+- **2026-10-23** · Show Hidden Characters marks table cells as InDesign does: every cell shows its own spaces, tabs and paragraph returns and an end-of-story # after its text (an empty cell shows just the #), and the table paragraph's ¶ sits after the table's bottom right corner. Cells showed no marks. No readiness percentage change. - **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. No readiness percentage change. - **2026-10-22** · Hyphenate Across Column (Hyphenation Settings; IDML HyphenateAcrossColumns) is applied: with it off, a paragraph whose line ending in an automatic hyphen would end a column or frame is re-composed without that hyphenation point, so the word moves on whole, as in InDesign. Opened and imported files keep their own setting, so their layouts don't change. No readiness percentage change. - **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change.diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs--- a/crates/ui-egui/src/canvas.rs+++ b/crates/ui-egui/src/canvas.rs@@ -2748,6 +2748,8 @@ fn ime_output(app: &mut DesignApp, ctx: &egui::Context, xf: &Xf) { }  /// View → Show Hidden Characters: ¶ paragraph ends, » tabs, · spaces, ¬ forced line breaks, # end of story.+/// As InDesign, every table cell shows its own marks (an empty cell its #), and a table's+/// paragraph return sits after the table's bottom right corner. fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) {     for story in doc.stories.values() {         let cs = app.session.cache.get(doc, story.id, None);@@ -2755,19 +2757,41 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc             let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue };             let m = a * doc.text_xf(it);             let col = layer_color(doc, it);-            for l in &ft.lines {-                let size = (l.ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32;-                let font = egui::FontId::proportional(size);-                for (x, mark, centered) in hidden_marks(&story.text, l) {-                    let p = xf.to_screen(m * Point::new(x, l.baseline));-                    let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM };-                    painter.text(p, align, mark, font.clone(), col);-                }+            for (p, mark, centered, ascent) in frame_hidden_marks(&story.text, ft) {+                let size = (ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32;+                let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM };+                painter.text(xf.to_screen(m * p), align, mark, egui::FontId::proportional(size), col);             }         }     } } +/// A frame's hidden-character marks in frame space (position at the baseline, mark, centered,+/// the line's ascent): its lines' marks, then its tables' — each text cell's own marks (nested+/// tables included) with its # where its text ends, and a table paragraph's ¶ after the last+/// row's bottom right corner. A table's own glyph-less line has no marks: its ¶ is that one.+fn frame_hidden_marks(text: &str, ft: &compose::FrameText) -> Vec<(Point, &'static str, bool, f64)> {+    let table_lines: Vec<usize> = ft.tables.iter().map(|t| t.line).collect();+    let mut out: Vec<_> = ft+        .lines+        .iter()+        .enumerate()+        .filter(|(i, _)| !table_lines.contains(i))+        .flat_map(|(_, l)| hidden_marks(text, l).into_iter().map(move |(x, mark, c)| (Point::new(x, l.baseline), mark, c, l.ascent)))+        .collect();+    for t in &ft.tables {+        for c in t.cells.iter().filter(|c| c.graphic.is_none()) {+            if let Some(cf) = c.text.frames.first() {+                out.extend(frame_hidden_marks(&c.source, cf).into_iter().map(|(p, mark, centered, a)| (p + c.origin.to_vec2(), mark, centered, a)));+            }+        }+        if t.last {+            out.push((Point::new(t.rect.x1 + 1.0, t.rect.y1), "¶", false, 12.0));+        }+    }+    out+}+ /// The hidden-character marks on line `l` of a story with text `text`: frame-space x, mark, and /// whether the mark is centered on x (spaces) or starts at it. fn hidden_marks(text: &str, l: &compose::Line) -> Vec<(f64, &'static str, bool)> {@@ -5672,6 +5696,38 @@ mod tests {             assert!(para_x >= space.x + space.adv, "{mark} at {para_x} overlaps the last space");         }     }++    /// As InDesign: an end-of-story # in every cell (after its text), a ¶ where a cell paragraph+    /// ends, and the table paragraph's ¶ just after the table's bottom right corner.+    #[test]+    fn table_cells_show_their_hidden_characters() {+        let mut d = Document::new(&designcraft_doc::build::NewDocument::default());+        let lid = d.default_layer();+        let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), DRect::new(0.0, 0.0, 300.0, 300.0), lid, "", Default::default()).unwrap();+        let data = vec![vec!["Name:".to_string(), "".into()], vec!["a\nb".into(), "".into()]];+        let t = designcraft_doc::Table::from_strings(d.alloc(), &data, 200.0, &Default::default(), &Default::default());+        d.story_mut(sid).unwrap().insert_table(0, t);+        let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default());+        let frag = &cs.frames[0].tables[0];+        // The whole frame: the table's own line adds no second ¶; the story's last paragraph+        // (after the table) has its #.+        let story = d.story(sid).unwrap().text.clone();+        let all = frame_hidden_marks(&story, &cs.frames[0]);+        let marks: Vec<_> = all.iter().filter(|m| frag.rect.inflate(2.0, 2.0).contains(m.0)).cloned().collect();+        let ends = |m: &str| marks.iter().filter(|x| x.1 == m).count();+        assert_eq!(ends("#"), 4, "one per cell: {marks:?}");+        assert_eq!(ends("¶"), 2, "the cell's first paragraph and the table's, once: {marks:?}");+        assert_eq!(all.iter().filter(|m| m.1 == "¶" && m.3 > 20.0).count(), 0, "no ¶ the size of the table: {all:?}");+        // Each # is inside its cell, after the cell's text.+        for c in &frag.cells {+            let inside = marks.iter().filter(|m| m.1 == "#" && c.rect.contains(m.0)).count();+            assert_eq!(inside, 1, "cell {},{}: {marks:?}", c.row, c.col);+        }+        let name = frag.cell(0, 0).unwrap();+        assert!(marks.iter().any(|m| m.1 == "#" && name.rect.contains(m.0) && m.0.x > name.origin.x + 20.0), "after Name:");+        let last = marks.last().unwrap();+        assert_eq!((last.0, last.1), (designcraft_geom::Point::new(frag.rect.x1 + 1.0, frag.rect.y1), "¶"));+    } }  #[cfg(test)]