mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 13:46:52 -06:00
Merge storytold/main into PR #1985
This commit is contained in:
@@ -385,6 +385,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
Newest first.
|
||||
|
||||
- **2026-10-29** · Inserting text with many returns no longer takes time that grows with their square (part of #1588): each return copied its paragraph's format with its own insert, moving every format after it. Splitting 20,000 returns into paragraphs took 15.8 s; placing a text file of them now takes 0.2 s, and one of a million returns 5.5 s. No readiness percentage change.
|
||||
- **2026-10-28** · Discretionary hyphens (U+00AD) break in the single-line composer with automatic hyphenation on or off, matching the paragraph composer. Unbroken soft hyphens remain invisible and zero-width, and words containing them keep only their discretionary break points (#1955). No readiness percentage change.
|
||||
- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). A break and the start option it meets count once, and breaks that tracked changes delete or a hidden condition hides don't count. No readiness percentage change.
|
||||
- **2026-10-28** · IDML export writes the document's default `StoryPreference` in `Resources/Preferences.xml`, as InDesign does. Only each story carried one, and IDML readers that take the spread reading order from the document's (Okapi's IDML filter) failed on DesignCraft's files. No readiness percentage change.
|
||||
- **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change.
|
||||
- **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change.
|
||||
@@ -457,6 +459,7 @@ Newest first.
|
||||
- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.
|
||||
- **2026-10-21** · The ¶ (Paragraph Formatting Controls) mode of the text Control panel has InDesign's Bulleted List and Numbered List buttons (with the default hanging indent) and Shading and Border: a checkbox, a button opening Paragraph Borders and Shading on that tab, and the colour. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change.
|
||||
- **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change.
|
||||
- **2026-10-17** · Inline and anchored objects paint under their frame's text, on screen and in PDF, as InDesign draws them: a picture whose frame reaches into the next line (a negative Y offset) no longer hides the top of that line. No readiness percentage change.
|
||||
- **2026-10-17** · Running headers: *first/last on page* read a page in text order (story order, then column by column), not by height — a multi-column page's right header showed the lowest entry of column 1 instead of the page's last entry. No readiness percentage change.
|
||||
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
|
||||
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
|
||||
|
||||
@@ -972,7 +972,15 @@ pub fn greedy(
|
||||
x += adv;
|
||||
shrink += sp.box_elastic(g).1.iter().sum::<f64>();
|
||||
if i + 1 < n && !glyphs[i + 1].is_space() && !g.no_break && g.break_after != Some(false) {
|
||||
if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/')
|
||||
if g.ch == SOFT_HYPHEN {
|
||||
if may_hyphenate(hyphens) {
|
||||
let hw = hyphen_width(g, &mut hy_cache);
|
||||
let hy = (hw - hyphen_hang(g, hw, sp)).max(0.0);
|
||||
if x + hy <= w + shrink + FIT_EPS {
|
||||
last_ok = Some((i + 1, true));
|
||||
}
|
||||
}
|
||||
} else if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/')
|
||||
|| g.break_after.unwrap_or_else(|| cjk_break(g, &glyphs[i + 1], sp))
|
||||
{
|
||||
last_ok = Some((i + 1, false));
|
||||
|
||||
@@ -930,18 +930,25 @@ fn line_has_wrapping_exclusion(frame: &FrameSpec, range: Range<usize>, column: R
|
||||
})
|
||||
}
|
||||
|
||||
fn previous_visible_break(doc: &Document, story: &Story, ranges: &[Range<usize>], deleted: &[bool], pi: usize) -> Option<StartParagraph> {
|
||||
/// The visible break ending the last placed paragraph before `pi`, which sends `pi` on.
|
||||
pub fn previous_visible_break(doc: &Document, story: &Story, ranges: &[Range<usize>], deleted: &[bool], pi: usize) -> Option<StartParagraph> {
|
||||
(0..pi).rev().find(|&p| !deleted.get(p).copied().unwrap_or(false)).and_then(|p| {
|
||||
let range = ranges.get(p)?;
|
||||
let (index, character) = story.text.get(range.clone())?.char_indices().next_back()?;
|
||||
let start = break_start(character)?;
|
||||
let (_, chars) = doc.styles.resolve_para(story.paras.get(p)?);
|
||||
let props = doc.styles.resolve_char(&chars, story.char_format_at(range.start + index + character.len_utf8()));
|
||||
let gone = props.change == designcraft_doc::ChangeMark::Deleted || doc.conditions_hide(&props.conditions);
|
||||
(!gone).then_some(start)
|
||||
let (index, _) = story.text.get(range.clone())?.char_indices().next_back()?;
|
||||
visible_break(doc, story, p, range.start + index)
|
||||
})
|
||||
}
|
||||
|
||||
/// The start option of the break at byte `at` in paragraph `p`, unless deleted or hidden.
|
||||
pub fn visible_break(doc: &Document, story: &Story, p: usize, at: usize) -> Option<StartParagraph> {
|
||||
let character = story.text.get(at..)?.chars().next()?;
|
||||
let start = break_start(character)?;
|
||||
let (_, chars) = doc.styles.resolve_para(story.paras.get(p)?);
|
||||
let props = doc.styles.resolve_char(&chars, story.char_format_at(at + character.len_utf8()));
|
||||
let gone = props.change == designcraft_doc::ChangeMark::Deleted || doc.conditions_hide(&props.conditions);
|
||||
(!gone).then_some(start)
|
||||
}
|
||||
|
||||
fn named_list_labels(doc: &Document, story: &Story, ranges: &[Range<usize>], deleted: &[bool]) -> Vec<Option<String>> {
|
||||
let mut counters: std::collections::HashMap<String, ListCounter> = std::collections::HashMap::new();
|
||||
story
|
||||
@@ -3483,8 +3490,8 @@ fn rewind(
|
||||
}
|
||||
|
||||
/// Paragraphs that tracked changes delete: all their text is deleted, and so is their return or
|
||||
/// the return before them (then they join a paragraph and add nothing to it).
|
||||
fn deleted_paras(story: &Story, ranges: &[Range<usize>]) -> Vec<bool> {
|
||||
/// the return before them (then they join a paragraph and add nothing to it). Composing skips them.
|
||||
pub fn deleted_paras(story: &Story, ranges: &[Range<usize>]) -> Vec<bool> {
|
||||
let mut spans: Vec<Range<usize>> = Vec::new();
|
||||
for (r, f) in story.runs() {
|
||||
if f.over.change != Some(designcraft_doc::ChangeMark::Deleted) {
|
||||
|
||||
+87
-13
@@ -1103,21 +1103,95 @@ fn hyphenate_across_column_off_keeps_nested_line_styles_on_their_lines() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discretionary_hyphen_replaces_automatic_points() {
|
||||
let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee";
|
||||
let para = ParaAttrs { align: Some(Align::Left), ..Default::default() };
|
||||
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 120.0, 1000.0), para);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let soft = text.find('\u{AD}').unwrap();
|
||||
for l in all_lines(&cs) {
|
||||
if l.hyphenated {
|
||||
let last = l.glyphs.iter().rfind(|g| g.len > 0).unwrap();
|
||||
assert!(last.byte + last.len >= soft && last.byte <= soft + 2, "{:?}", &text[l.range.clone()]);
|
||||
fn discretionary_hyphen_breaks_with_both_composers_and_hyphenation_settings() {
|
||||
let text = "Wir möchten uns ganz besonders herzlich bei folgenden Personen bedanken, die auf unterschied\u{ad}lichste Weise zur Entstehung dieses Lehrbuches beigetragen haben:";
|
||||
let soft_end = text.find('\u{ad}').unwrap() + '\u{ad}'.len_utf8();
|
||||
for composer in [Composer::SingleLine, Composer::Paragraph] {
|
||||
for hyphenate in [false, true] {
|
||||
let para = ParaAttrs { composer: Some(composer), hyphenate: Some(hyphenate), ..Default::default() };
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 430.0, 1000.0), para);
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.size = Some(9.5));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let first = all_lines(&cs)[0];
|
||||
assert_eq!(first.range.end, soft_end, "{composer:?}, hyphenate={hyphenate}: {:?}", &text[first.range.clone()]);
|
||||
assert!(first.hyphenated);
|
||||
let hyphen = first.glyphs.last().unwrap();
|
||||
assert!(hyphen.visible && hyphen.len == 0 && hyphen.adv > 0.0);
|
||||
}
|
||||
}
|
||||
// Hyphenation points of the word itself: none besides the discretionary one.
|
||||
let lines = all_lines(&cs);
|
||||
assert!(lines.iter().filter(|l| l.hyphenated).count() <= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discretionary_hyphen_before_cjk_single_line() {
|
||||
discretionary_hyphen_before_cjk(Composer::SingleLine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discretionary_hyphen_before_cjk_paragraph() {
|
||||
discretionary_hyphen_before_cjk(Composer::Paragraph);
|
||||
}
|
||||
|
||||
fn discretionary_hyphen_before_cjk(composer: Composer) {
|
||||
let text = "abc\u{ad}\u{65e5}\u{672c}\u{8a9e}\u{65e5}\u{672c}\u{8a9e}";
|
||||
let soft_end = text.find('\u{ad}').unwrap() + '\u{ad}'.len_utf8();
|
||||
for hyphenate in [false, true] {
|
||||
let para = ParaAttrs { composer: Some(composer), hyphenate: Some(hyphenate), ..Default::default() };
|
||||
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 24.0, 1000.0), para);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let first = all_lines(&cs)[0];
|
||||
assert_eq!(first.range.end, soft_end, "{composer:?}, hyphenate={hyphenate}: {:?}", &text[first.range.clone()]);
|
||||
assert!(first.hyphenated, "{composer:?}, hyphenate={hyphenate}");
|
||||
let hyphen = first.glyphs.last().unwrap();
|
||||
assert!(hyphen.visible && hyphen.len == 0 && hyphen.adv > 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discretionary_hyphen_is_invisible_and_zero_width_when_unbroken() {
|
||||
for composer in [Composer::SingleLine, Composer::Paragraph] {
|
||||
let mut widths = Vec::new();
|
||||
for text in ["unterschiedlichste", "unterschied\u{ad}lichste"] {
|
||||
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 1000.0, 100.0), ParaAttrs { composer: Some(composer), ..Default::default() });
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(!lines[0].hyphenated);
|
||||
if let Some(soft) = text.find('\u{ad}') {
|
||||
let glyph = lines[0].glyphs.iter().find(|g| g.byte == soft).unwrap();
|
||||
assert!(!glyph.visible && glyph.adv == 0.0);
|
||||
}
|
||||
widths.push(lines[0].end_x);
|
||||
}
|
||||
assert!((widths[0] - widths[1]).abs() < 1e-6, "{composer:?}: {widths:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discretionary_hyphen_replaces_automatic_points() {
|
||||
let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee";
|
||||
let soft = text.find('\u{AD}').unwrap();
|
||||
for composer in [Composer::SingleLine, Composer::Paragraph] {
|
||||
let mut discretionary_breaks = 0;
|
||||
let mut automatic_breaks = 0;
|
||||
for width in (90..181).step_by(10) {
|
||||
for discretionary in [false, true] {
|
||||
let source = if discretionary { text.to_string() } else { text.replace('\u{AD}', "") };
|
||||
let para = ParaAttrs { composer: Some(composer), hyphenate: Some(true), align: Some(Align::Left), ..Default::default() };
|
||||
let (d, sid, _) = doc_with(&source, Rect::new(0.0, 0.0, f64::from(width), 1000.0), para);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
for l in all_lines(&cs).into_iter().filter(|l| l.hyphenated) {
|
||||
if discretionary {
|
||||
assert_eq!(l.range.end, soft + '\u{AD}'.len_utf8(), "{composer:?}: {:?}", &source[l.range.clone()]);
|
||||
discretionary_breaks += 1;
|
||||
} else {
|
||||
automatic_breaks += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(discretionary_breaks > 0, "{composer:?}: discretionary breaks exercised");
|
||||
assert!(automatic_breaks > 0, "{composer:?}: pattern breaks exercised without U+00AD");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! until the whole story fits (InDesign's Shift-click with a loaded text cursor).
|
||||
|
||||
use designcraft_doc::{
|
||||
CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, Story, StoryId, TextDirection, TextFileLink, TextSel,
|
||||
CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, StartParagraph, Story, StoryId, TextDirection, TextFileLink, TextSel,
|
||||
};
|
||||
use designcraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
@@ -197,21 +197,60 @@ pub(crate) fn reimport(
|
||||
Ok(imp.warnings)
|
||||
}
|
||||
|
||||
/// Is the story overset in its frames, and how much text do its full frames hold (bytes, frames)?
|
||||
/// Is the story overset in its frames, how much text do its full frames hold (bytes, frames), and
|
||||
/// how many frames does the overset text start on its own (breaks, Start Paragraph options)?
|
||||
/// A frame a column, frame or page break ends early is left out: it says nothing about how much
|
||||
/// a frame holds.
|
||||
fn fit(d: &Document, sid: StoryId) -> (Option<usize>, usize, usize) {
|
||||
/// a frame holds. So is one a Start Paragraph option ends early (#1209).
|
||||
fn fit(d: &Document, sid: StoryId) -> (Option<usize>, usize, usize, usize) {
|
||||
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
|
||||
let text = d.story(sid).map_or("", |s| s.text.as_str());
|
||||
let Some(story) = d.story(sid) else { return (cs.overset_at, 0, 0, 0) };
|
||||
let text = story.text.as_str();
|
||||
let ranges = story.para_ranges();
|
||||
let deleted = designcraft_compose::deleted_paras(story, &ranges);
|
||||
let placed = |p: usize| !deleted.get(p).copied().unwrap_or(false);
|
||||
let starts = |p: usize| placed(p) && story.paras.get(p).is_some_and(|pf| d.styles.resolve_para(pf).0.start_paragraph != StartParagraph::Anywhere);
|
||||
// A frame's range stops before the paragraph's return; composing skips deleted paragraphs.
|
||||
let moved_on = |end: usize| {
|
||||
let next = if text.as_bytes().get(end) == Some(&b'\n') { end.saturating_add(1) } else { end };
|
||||
next.checked_sub(1).is_some_and(|i| text.as_bytes().get(i) == Some(&b'\n'))
|
||||
&& (story.para_at(next)..story.paras.len()).find(|&p| placed(p)).is_some_and(starts)
|
||||
};
|
||||
let (mut held, mut full) = (0, 0);
|
||||
for f in &cs.frames {
|
||||
let Some(shown) = text.get(f.range.clone()) else { continue };
|
||||
if !shown.is_empty() && !shown.chars().any(designcraft_doc::is_break_char) {
|
||||
if !shown.is_empty() && !shown.chars().any(designcraft_doc::is_break_char) && !moved_on(f.range.end) {
|
||||
held += shown.len();
|
||||
full += 1;
|
||||
}
|
||||
}
|
||||
(cs.overset_at, held, full)
|
||||
let forced = cs.overset_at.map_or(0, |at| {
|
||||
let first = story.para_at(at);
|
||||
// The paragraph the overset text starts with counts when it starts there.
|
||||
let from = if at.checked_sub(1).is_none_or(|i| text.as_bytes().get(i) == Some(&b'\n')) { first } else { first.saturating_add(1) };
|
||||
// Once per paragraph: after a break, its start option only fixes odd or even page parity.
|
||||
let sent = (from..ranges.len())
|
||||
.filter(|&p| starts(p) || (placed(p) && designcraft_compose::previous_visible_break(d, story, &ranges, &deleted, p).is_some()))
|
||||
.count();
|
||||
// A break inside a paragraph moves the rest of it.
|
||||
let inside: usize = (first..ranges.len())
|
||||
.filter(|&p| placed(p))
|
||||
.filter_map(|p| Some((p, ranges.get(p)?.clone())))
|
||||
.map(|(p, r)| {
|
||||
let Some(t) = text.get(r.clone()) else { return 0 };
|
||||
let last = t.char_indices().next_back().map(|(i, _)| i);
|
||||
t.char_indices()
|
||||
.filter(|&(i, c)| {
|
||||
designcraft_doc::is_break_char(c)
|
||||
&& Some(i) != last
|
||||
&& r.start.saturating_add(i) >= at
|
||||
&& designcraft_compose::visible_break(d, story, p, r.start.saturating_add(i)).is_some()
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.sum();
|
||||
sent.saturating_add(inside)
|
||||
});
|
||||
(cs.overset_at, held, full, forced)
|
||||
}
|
||||
|
||||
/// The frames autoflow sets on a page: one per column inside the margins, in the order a story
|
||||
@@ -256,12 +295,13 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
|
||||
// The next page of the document autoflow may set frames on; it only moves forward.
|
||||
let mut following = 0;
|
||||
loop {
|
||||
let (overset, held, full) = fit(d, sid);
|
||||
let (overset, held, full, forced) = fit(d, sid);
|
||||
let Some(at) = overset else { break };
|
||||
let len = d.story(sid).map_or(0, |s| s.len());
|
||||
// Frames still needed at the rate the full ones are filled (at least one); with no full
|
||||
// frame to go by yet, one more.
|
||||
let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).max(1);
|
||||
// Frames still needed at the rate the full ones are filled, plus one per frame the text
|
||||
// starts on its own (at least one); with neither to go by yet, one more.
|
||||
let rate = held.checked_div(full).map_or(0, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1)));
|
||||
let want = rate.saturating_add(forced).max(1);
|
||||
let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad(cmd, "the story has no frame"))?;
|
||||
let page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1));
|
||||
let layer = d.default_layer();
|
||||
|
||||
@@ -307,3 +307,117 @@ fn autoflow_adds_the_pages_odd_and_even_page_breaks_ask_for() {
|
||||
assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None, "{brk:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_sizes_no_frame_by_one_a_start_option_ends_early() {
|
||||
// Regression (#1209): the title frame a start option ends early isn't a frame's worth of text.
|
||||
use std::io::Write;
|
||||
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
|
||||
let para = |style: &str, text: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{style}"/></w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#);
|
||||
let body = [para("Title", "Front"), para("Chapter", "Chapter one"), para("Body", "lorem ipsum dolor sit amet ".repeat(60).trim())].concat();
|
||||
let styles: String = ["Title", "Chapter", "Body"]
|
||||
.iter()
|
||||
.map(|s| format!(r#"<w:style w:type="paragraph" w:styleId="{s}"><w:name w:val="{s}"/></w:style>"#))
|
||||
.collect();
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for (n, c) in [
|
||||
("word/document.xml", format!(r#"<w:document {W}><w:body>{body}</w:body></w:document>"#)),
|
||||
("word/styles.xml", format!(r#"<w:styles {W}>{styles}</w:styles>"#)),
|
||||
] {
|
||||
w.start_file(n, o).unwrap();
|
||||
w.write_all(c.as_bytes()).unwrap();
|
||||
}
|
||||
let docx = w.finish().unwrap().into_inner();
|
||||
for (start, pages) in [("nextOddPage", 5), ("nextEvenPage", 4)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 3, "facingPages": true})).unwrap();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Chapter", "para": {"startParagraph": start}})).unwrap();
|
||||
let ids = s.doc().unwrap().doc.next_id;
|
||||
let r = s
|
||||
.execute("file.place", &json!({"base64": super::super::file::base64_encode(&docx), "name": "front.docx", "page": 3, "autoflow": true}))
|
||||
.unwrap();
|
||||
assert_eq!(r["overset"], false, "{start}: {r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.page_count(), pages, "{start}: {r}");
|
||||
// A few ids per page it keeps, not hundreds for pages it builds and removes.
|
||||
assert!(d.next_id - ids < 100, "{start}: {} ids", d.next_id - ids);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_looks_past_a_deleted_paragraph_for_the_start_option() {
|
||||
// The title frame ends before a tracked deletion; the chapter after it starts on an odd page.
|
||||
use designcraft_doc::{ChangeMark, build::NewDocument};
|
||||
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() });
|
||||
let sid = d.settings.primary_story.unwrap();
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
st.insert(0, &format!("Front\nGone\nChapter one\n{}", "lorem ipsum dolor sit amet ".repeat(400).trim()));
|
||||
st.paras[2].para.start_paragraph = Some(StartParagraph::NextOddPage);
|
||||
st.format_chars(6..11, |f| f.over.change = Some(ChangeMark::Deleted));
|
||||
let ids = d.next_id;
|
||||
assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 4);
|
||||
assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None);
|
||||
assert!(d.next_id - ids < 100, "{} ids", d.next_id - ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_asks_for_every_frame_the_start_options_ask_for_at_once() {
|
||||
// A paragraph per page: no frame sets a fill rate, yet one round shouldn't add one frame.
|
||||
use designcraft_doc::build::NewDocument;
|
||||
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() });
|
||||
let sid = d.settings.primary_story.unwrap();
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
st.insert(0, (0..200).map(|i| format!("Chapter {i}\n")).collect::<String>().trim_end());
|
||||
for p in &mut st.paras {
|
||||
p.para.start_paragraph = Some(StartParagraph::NextPage);
|
||||
}
|
||||
let ids = d.next_id;
|
||||
assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 199);
|
||||
assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None);
|
||||
assert!(d.next_id - ids < 2000, "{} ids", d.next_id - ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_asks_no_frame_for_a_deleted_or_hidden_break() {
|
||||
// Composing skips breaks in the overset text that tracked changes delete or a condition hides.
|
||||
use designcraft_doc::{ChangeMark, Condition, PAGE_BREAK, build::NewDocument};
|
||||
for hide in [false, true] {
|
||||
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() });
|
||||
d.conditions.push(Condition { name: "Hidden".into(), color: [0, 0, 0], visible: false });
|
||||
let sid = d.settings.primary_story.unwrap();
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
let body = "lorem ipsum dolor sit amet ".repeat(300);
|
||||
let gone = format!("x{PAGE_BREAK}\n").repeat(200);
|
||||
st.insert(0, &format!("{}\n{gone}{}", body.trim(), body.trim()));
|
||||
let at = body.trim().len() + 1;
|
||||
if hide {
|
||||
for (i, _) in gone.match_indices(PAGE_BREAK) {
|
||||
st.format_chars(at + i..at + i + PAGE_BREAK.len_utf8(), |f| f.over.conditions = Some(vec!["Hidden".into()]));
|
||||
}
|
||||
} else {
|
||||
st.format_chars(at..at + gone.len(), |f| f.over.change = Some(ChangeMark::Deleted));
|
||||
}
|
||||
let ids = d.next_id;
|
||||
autoflow(&mut d, sid, 2000, "file.place", false).unwrap();
|
||||
assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None, "hide {hide}");
|
||||
assert!(d.next_id - ids < 100, "hide {hide}: {} pages, {} ids", d.page_count(), d.next_id - ids);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_counts_a_break_and_the_start_option_it_meets_once() {
|
||||
// Each chapter ends in a page break and starts on the next page: one new page each, not two.
|
||||
use designcraft_doc::{PAGE_BREAK, build::NewDocument};
|
||||
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() });
|
||||
let sid = d.settings.primary_story.unwrap();
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
st.insert(0, (0..200).map(|i| format!("Chapter {i}{PAGE_BREAK}\n")).collect::<String>().trim_end());
|
||||
for p in &mut st.paras {
|
||||
p.para.start_paragraph = Some(StartParagraph::NextPage);
|
||||
}
|
||||
let ids = d.next_id;
|
||||
assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 199);
|
||||
assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None);
|
||||
assert!(d.next_id - ids < 2000, "{} ids", d.next_id - ids);
|
||||
}
|
||||
|
||||
@@ -281,6 +281,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let style = str_param(p, "style").unwrap_or("").to_string();
|
||||
let tag = str_param(p, "tag").map(str::to_string);
|
||||
if let Some(t) = &tag
|
||||
&& !valid_name(t)
|
||||
{
|
||||
return Err(bad("xml.mapStyle", format!("`{t}` isn't a valid XML name")));
|
||||
}
|
||||
if s.doc()?.doc.styles.para(&style).is_none() {
|
||||
return Err(bad("xml.mapStyle", format!("no paragraph style `{style}`")));
|
||||
}
|
||||
@@ -500,6 +505,19 @@ mod tests {
|
||||
assert!(x.contains("<story>ABC</story>"), "{x:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_style_rejects_invalid_tag_name() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap();
|
||||
assert!(s.execute("xml.mapStyle", &json!({"style": "Head", "tag": "a b><x"})).is_err());
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.xml.style_map.is_empty());
|
||||
assert!(d.xml.tags.iter().all(|t| t.name != "a b><x"));
|
||||
s.execute("xml.mapStyle", &json!({"style": "Head", "tag": "title"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.xml.style_map.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_export_and_import_xml() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -308,13 +308,16 @@ impl Exporter<'_> {
|
||||
if frame_art {
|
||||
s.end_tagged();
|
||||
}
|
||||
self.draw_leaf_content(s, it, bp, path, page);
|
||||
// A text frame's anchored objects are drawn under its text when they can be.
|
||||
let drawn = self.draw_leaf_content(s, it, xf, bp, path, page);
|
||||
self.draw_leaf_stroke(s, it, bp, path, frame_art);
|
||||
s.pop();
|
||||
for _ in 0..pushes {
|
||||
s.pop();
|
||||
}
|
||||
if let Content::Text(tfr) = &it.content {
|
||||
if let Content::Text(tfr) = &it.content
|
||||
&& !drawn
|
||||
{
|
||||
let cs = self.composed(tfr.story, page);
|
||||
if let Some(ft) = cs.frame(it.id) {
|
||||
self.frame_objects(s, ft, xf * self.doc.text_local(it), page);
|
||||
@@ -347,8 +350,10 @@ impl Exporter<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_leaf_content(&mut self, s: &mut Surface, it: &Item, bp: &BezPath, path: Option<&Path>, page: Option<usize>) {
|
||||
/// The item's content; returns whether a text frame's anchored objects were drawn with it.
|
||||
fn draw_leaf_content(&mut self, s: &mut Surface, it: &Item, xf: Affine, bp: &BezPath, path: Option<&Path>, page: Option<usize>) -> bool {
|
||||
let doc = self.doc;
|
||||
let mut drawn = false;
|
||||
match &it.content {
|
||||
Content::Graphic(g) => {
|
||||
let g = &if it.media.is_some() { it.drawn_graphic(&self.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() };
|
||||
@@ -391,9 +396,12 @@ impl Exporter<'_> {
|
||||
if local != Affine::IDENTITY {
|
||||
s.push_transform(&tf(local));
|
||||
}
|
||||
self.text_area = (local.determinant().abs() > 1e-12).then(|| local.inverse().transform_rect_bbox(it.inner_bounds()));
|
||||
self.frame_text(s, &cs, ft, text);
|
||||
self.text_area = None;
|
||||
// Anchored text frames now draw inside `frame_text`: restore the host's area
|
||||
// after, so an object drawn there doesn't clear it for the host's glyphs.
|
||||
let area = (local.determinant().abs() > 1e-12).then(|| local.inverse().transform_rect_bbox(it.inner_bounds()));
|
||||
let outer = std::mem::replace(&mut self.text_area, area);
|
||||
drawn = self.frame_text(s, &cs, ft, text, Some((xf * local, page)));
|
||||
self.text_area = outer;
|
||||
if local != Affine::IDENTITY {
|
||||
s.pop();
|
||||
}
|
||||
@@ -401,6 +409,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
drawn
|
||||
}
|
||||
|
||||
fn draw_leaf_stroke(&mut self, s: &mut Surface, it: &Item, bp: &BezPath, path: Option<&Path>, frame_art: bool) {
|
||||
@@ -419,7 +428,7 @@ impl Exporter<'_> {
|
||||
|
||||
/// Draw embedded page items after restoring the surface, retaining spread-space
|
||||
/// transforms for page culling (also for anonymous note and cell stories).
|
||||
fn frame_objects(&mut self, s: &mut Surface, ft: &designcraft_compose::FrameText, xf: Affine, page: Option<usize>) {
|
||||
pub(crate) fn frame_objects(&mut self, s: &mut Surface, ft: &designcraft_compose::FrameText, xf: Affine, page: Option<usize>) {
|
||||
let mut pending = vec![(ft, xf)];
|
||||
while let Some((frame, transform)) = pending.pop() {
|
||||
for object in &frame.objects {
|
||||
|
||||
+97
-4
@@ -76,6 +76,28 @@ fn exports_valid_pdf_with_one_page_per_page() {
|
||||
assert_eq!(pdf.pages().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discretionary_hyphen_pdf_text_follows_visible_breaks() {
|
||||
let text = "Wir möchten uns ganz besonders herzlich bei folgenden Personen bedanken, die auf unterschied\u{ad}lichste Weise zur Entstehung dieses Lehrbuches beigetragen haben:";
|
||||
for composer in [designcraft_doc::Composer::SingleLine, designcraft_doc::Composer::Paragraph] {
|
||||
for (width, expected) in [(430.0, "unterschied-lichste"), (540.0, "unterschiedlichste")] {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let para = designcraft_doc::ParaAttrs { composer: Some(composer), hyphenate: Some(false), ..Default::default() };
|
||||
let (_, sid) = d
|
||||
.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 36.0 + width, 400.0), lid, text, ParaFormat { para, ..Default::default() })
|
||||
.unwrap();
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.size = Some(9.5));
|
||||
for tagged in [false, true] {
|
||||
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions { tagged, ..Default::default() }).unwrap();
|
||||
let extracted = extract_text(&bytes).concat();
|
||||
assert!(extracted.contains(expected), "{composer:?}, width={width}, tagged={tagged}: {extracted:?}");
|
||||
assert!(!extracted.contains('\u{ad}'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_real_text() {
|
||||
let d = doc_with_text("Hello DesignCraft — efficient affine");
|
||||
@@ -473,13 +495,19 @@ fn drawn(bytes: &[u8]) -> (Vec<(String, kurbo::Rect)>, Vec<kurbo::Rect>) {
|
||||
|
||||
/// The page's paint operations in order: `fill`/`stroke` + ` text`/` path` and the stroke width.
|
||||
fn paint_order(bytes: &[u8]) -> Vec<(String, f32)> {
|
||||
paint_events(bytes, false)
|
||||
}
|
||||
|
||||
/// [`paint_order`], with transparency groups when `groups`: `group` (and its opacity) and
|
||||
/// `end group`.
|
||||
fn paint_events(bytes: &[u8], groups: bool) -> Vec<(String, f32)> {
|
||||
use hayro_interpret::font::Glyph;
|
||||
use hayro_interpret::{
|
||||
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
|
||||
interpret_page,
|
||||
};
|
||||
#[derive(Default)]
|
||||
struct Rec(Vec<(String, f32)>);
|
||||
struct Rec(Vec<(String, f32)>, bool);
|
||||
impl Device<'_> for Rec {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
@@ -490,7 +518,11 @@ fn paint_order(bytes: &[u8]) -> Vec<(String, f32)> {
|
||||
});
|
||||
}
|
||||
fn push_clip_path(&mut self, _: &ClipPath) {}
|
||||
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
|
||||
fn push_transparency_group(&mut self, opacity: f32, _: Option<SoftMask<'_>>, _: BlendMode) {
|
||||
if self.1 {
|
||||
self.0.push(("group".into(), opacity));
|
||||
}
|
||||
}
|
||||
fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, m: &GlyphDrawMode) {
|
||||
self.0.push(match m {
|
||||
GlyphDrawMode::Fill => ("fill text".into(), 0.0),
|
||||
@@ -501,13 +533,17 @@ fn paint_order(bytes: &[u8]) -> Vec<(String, f32)> {
|
||||
}
|
||||
fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn pop_transparency_group(&mut self) {}
|
||||
fn pop_transparency_group(&mut self) {
|
||||
if self.1 {
|
||||
self.0.push(("end group".into(), 0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse");
|
||||
let cache = InterpreterCache::new();
|
||||
let page = &pdf.pages()[0];
|
||||
let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default());
|
||||
let mut rec = Rec::default();
|
||||
let mut rec = Rec(Vec::new(), groups);
|
||||
interpret_page(page, &mut ctx, &mut rec);
|
||||
rec.0
|
||||
}
|
||||
@@ -928,6 +964,63 @@ fn a_swatch_colours_a_greyscale_image() {
|
||||
assert!(!s.contains("PANTONE#20Test"), "a colour image keeps its colours");
|
||||
}
|
||||
|
||||
/// An inline object is painted under its frame's text, as InDesign draws it: a picture whose
|
||||
/// frame reaches down into the next line (a negative Y offset) doesn't hide that line.
|
||||
#[test]
|
||||
fn inline_objects_are_painted_under_the_text() {
|
||||
use designcraft_doc::{AnchorPosition, AnchoredObject};
|
||||
let mut d = doc_with_text("A\nWWWW");
|
||||
let sid = *d.stories.keys().next().unwrap();
|
||||
let mut item = Item::new(
|
||||
ItemId(d.alloc()),
|
||||
d.default_layer(),
|
||||
designcraft_doc::Shape::Rectangle,
|
||||
designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 30.0)),
|
||||
);
|
||||
item.fill = Fill::swatch(designcraft_color::swatch::BLACK);
|
||||
d.story_mut(sid).unwrap().insert_object(1, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: -20.0 }));
|
||||
let order = paint_order(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap());
|
||||
let first_text = order.iter().position(|(op, _)| op.ends_with("text")).unwrap();
|
||||
let the_box = order.iter().position(|(op, _)| op == "fill path").unwrap();
|
||||
assert!(the_box < first_text, "{order:?}");
|
||||
}
|
||||
|
||||
/// An anchored object is painted as part of its host frame, as the screen renderer paints it:
|
||||
/// inside the frame's transparency group (so a half-transparent frame shows its inline picture
|
||||
/// half-transparent too), after the frame's fill and before its text and stroke.
|
||||
#[test]
|
||||
fn inline_objects_share_their_frames_transparency() {
|
||||
use designcraft_doc::{AnchorPosition, AnchoredObject};
|
||||
let mut d = doc_with_text("A\nWWWW");
|
||||
let sid = *d.stories.keys().next().unwrap();
|
||||
let fid = d.stories[&sid].frames[0];
|
||||
{
|
||||
let host = d.item_mut(fid).unwrap();
|
||||
host.opacity = 0.5;
|
||||
host.fill = Fill::swatch(designcraft_color::swatch::PAPER);
|
||||
host.stroke.weight = 3.0;
|
||||
host.stroke.swatch = designcraft_color::swatch::BLACK.into();
|
||||
}
|
||||
let mut item = Item::new(
|
||||
ItemId(d.alloc()),
|
||||
d.default_layer(),
|
||||
designcraft_doc::Shape::Rectangle,
|
||||
designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 30.0)),
|
||||
);
|
||||
item.fill = Fill::swatch(designcraft_color::swatch::BLACK);
|
||||
d.story_mut(sid).unwrap().insert_object(1, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: -20.0 }));
|
||||
let ev = paint_events(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(), true);
|
||||
let at = |op: &str| ev.iter().position(|(o, _)| o == op).unwrap_or_else(|| panic!("no {op}: {ev:?}"));
|
||||
let group = ev.iter().position(|(o, a)| o == "group" && (*a - 0.5).abs() < 1e-3).unwrap_or_else(|| panic!("no half-opacity group: {ev:?}"));
|
||||
let end = group + ev[group..].iter().position(|(o, _)| o == "end group").unwrap();
|
||||
// Fills in order: the frame's, then the anchored box's.
|
||||
let fills: Vec<usize> = ev.iter().enumerate().filter(|(_, (o, _))| o == "fill path").map(|(i, _)| i).collect();
|
||||
assert!(fills.len() >= 2, "{ev:?}");
|
||||
let the_box = fills[1];
|
||||
assert!(group < the_box && the_box < end, "the box is inside the frame's group: {ev:?}");
|
||||
assert!(the_box < at("fill text") && at("fill text") < at("stroke path") && at("stroke path") < end, "{ev:?}");
|
||||
}
|
||||
|
||||
/// The export asks for each story's composition once per page it is shown on: a parent page's
|
||||
/// page number still reads its own page everywhere, and a story threaded across pages shows each
|
||||
/// frame's own lines.
|
||||
|
||||
+25
-3
@@ -262,7 +262,17 @@ impl Exporter<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn frame_text(&mut self, s: &mut Surface, cs: &ComposedStory, ft: &FrameText, story: &str) {
|
||||
/// The frame's text. With `objects` (the frame text space → page transform and the page),
|
||||
/// the frame's anchored objects are drawn too, over its paragraph shading and under its text;
|
||||
/// returns whether they were.
|
||||
pub(crate) fn frame_text(
|
||||
&mut self,
|
||||
s: &mut Surface,
|
||||
cs: &ComposedStory,
|
||||
ft: &FrameText,
|
||||
story: &str,
|
||||
objects: Option<(Affine, Option<usize>)>,
|
||||
) -> bool {
|
||||
// Paragraph shading and rules under the text.
|
||||
self.as_artifact(s, |me, s| {
|
||||
for d in ft.decos.iter().filter(|d| !d.nonprinting) {
|
||||
@@ -277,6 +287,17 @@ impl Exporter<'_> {
|
||||
me.tables(s, ft);
|
||||
}
|
||||
});
|
||||
// Anchored objects under the text (as InDesign draws them): a picture whose frame reaches
|
||||
// into the next line doesn't hide that line. Objects keep their page-space transforms.
|
||||
let drawn = match objects {
|
||||
Some((xf, page)) if xf.determinant().abs() > 1e-12 => {
|
||||
s.push_transform(&tf(xf.inverse()));
|
||||
self.frame_objects(s, ft, xf, page);
|
||||
s.pop();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
// All underlines precede all glyph runs, including ink overlapping from another run.
|
||||
let underlines = text_rules(cs, ft, true);
|
||||
if !underlines.is_empty() {
|
||||
@@ -301,12 +322,13 @@ impl Exporter<'_> {
|
||||
if let Some(nft) = n.text.frames.first() {
|
||||
s.push_transform(&tf(Affine::translate(n.origin.to_vec2())));
|
||||
let area = std::mem::replace(&mut me.text_area, lines_area(nft));
|
||||
me.frame_text(s, &n.text, nft, &n.source);
|
||||
me.frame_text(s, &n.text, nft, &n.source, None);
|
||||
me.text_area = area;
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
});
|
||||
drawn
|
||||
}
|
||||
|
||||
/// One table cell's graphic, or its text composed in the cell's own area.
|
||||
@@ -322,7 +344,7 @@ impl Exporter<'_> {
|
||||
} else if let Some(cft) = c.text.frames.first() {
|
||||
s.push_transform(&tf(Affine::translate(c.origin.to_vec2())));
|
||||
let area = std::mem::replace(&mut self.text_area, lines_area(cft));
|
||||
self.frame_text(s, &c.text, cft, &c.source);
|
||||
self.frame_text(s, &c.text, cft, &c.source, None);
|
||||
self.text_area = area;
|
||||
s.pop();
|
||||
}
|
||||
|
||||
@@ -669,3 +669,29 @@ fn content_crop_box_is_measured_without_rendering_the_page() {
|
||||
assert!(frac.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-6), "{frac:?} vs {want:?}");
|
||||
assert!((size.0 - 400.0).abs() < 1e-3 && (size.1 - 497.0).abs() < 1e-3, "{size:?}");
|
||||
}
|
||||
|
||||
/// An inline object is painted under its frame's text, as InDesign draws it: a picture
|
||||
/// whose frame reaches down into the next line (a negative Y offset) doesn't hide that line.
|
||||
#[test]
|
||||
fn inline_objects_are_painted_under_the_text() {
|
||||
use designcraft_doc::{AnchorPosition, AnchoredObject, ItemId, Shape, SpreadRef};
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let (fid, sid) =
|
||||
d.add_text_frame(SpreadRef::Doc(0), Rect::new(20.0, 20.0, 300.0, 300.0), d.default_layer(), "A\nWWWW", ParaFormat::default()).unwrap();
|
||||
let mut item =
|
||||
Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 250.0, 60.0)));
|
||||
item.fill = Fill::swatch(designcraft_color::swatch::PAPER);
|
||||
d.story_mut(sid).unwrap().insert_object(1, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: -40.0 }));
|
||||
let cache = Cache::new();
|
||||
let cs = cache.get(&d, sid, None);
|
||||
let (ft, obj) = (&cs.frames[0], &cs.frames[0].objects[0]);
|
||||
let l = ft.lines.iter().find(|l| l.range.start > 1).unwrap();
|
||||
// The second line lies inside the white box.
|
||||
assert!(obj.origin.y + obj.size.1 > l.baseline && obj.origin.y < l.baseline - l.ascent, "{:?} {}", obj.origin, l.baseline);
|
||||
let m = d.text_xf(d.item(fid).unwrap());
|
||||
let image = Renderer::new().render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap();
|
||||
let (a, b) = (m * designcraft_geom::Point::new(l.x0, l.baseline - l.ascent), m * designcraft_geom::Point::new(l.end_x, l.baseline));
|
||||
let dark =
|
||||
(a.y as u32..b.y as u32).flat_map(|y| (a.x as u32..b.x as u32).map(move |x| (x, y))).filter(|&(x, y)| image.pixel(x, y)[0] < 100).count();
|
||||
assert!(dark > 20, "the line's glyphs show over the box: {dark} dark pixels");
|
||||
}
|
||||
|
||||
@@ -387,6 +387,11 @@ impl Renderer {
|
||||
if !ft.tables.is_empty() {
|
||||
self.draw_tables(ctx, f, ft, xf);
|
||||
}
|
||||
// Anchored objects ride on their lines, under the frame's text (as InDesign draws them):
|
||||
// a picture whose frame reaches into the next line doesn't hide that line.
|
||||
for o in &ft.objects {
|
||||
self.draw_item(ctx, f, &o.object.item, xf * Affine::translate(o.origin.to_vec2()), ShownOn::Own);
|
||||
}
|
||||
draw_text_indicators(ctx, f, cs, ft, m);
|
||||
let scale = m.determinant().abs().sqrt();
|
||||
let greek_px = f.opts.greek_below_px;
|
||||
@@ -461,10 +466,6 @@ impl Renderer {
|
||||
self.draw_text(ctx, f, &n.text, nft, xf * Affine::translate(n.origin.to_vec2()));
|
||||
}
|
||||
}
|
||||
// Anchored objects ride on their lines.
|
||||
for o in &ft.objects {
|
||||
self.draw_item(ctx, f, &o.object.item, xf * Affine::translate(o.origin.to_vec2()), ShownOn::Own);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,10 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
|
||||
let (name, value) = tag.split_once(':').map_or((tag.as_str(), ""), |(a, b)| (a, b));
|
||||
match name {
|
||||
n if n.starts_with("0x") => {
|
||||
if let Ok(u) = u32::from_str_radix(&n[2..], 16)
|
||||
// `from_str_radix` accepts a leading '+', so check the digits first.
|
||||
if let Some(digits) = n.get(2..)
|
||||
&& digits.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
&& let Ok(u) = u32::from_str_radix(digits, 16)
|
||||
&& let Some(ch) = units.push(u)
|
||||
{
|
||||
push(&mut paras, ch, &fmt);
|
||||
@@ -311,6 +314,16 @@ mod tests {
|
||||
assert_eq!(i.char_styles[0].name, " Em ");
|
||||
}
|
||||
|
||||
/// `from_str_radix` accepts a leading `+`, so `<0x+41>` inserted an 'A'. A signed or empty
|
||||
/// code is not a code point tag and is skipped.
|
||||
#[test]
|
||||
fn a_signed_hex_code_tag_is_skipped() {
|
||||
for t in ["<0x+41>", "<0x-41>", "<0x>", "<0xZZ>"] {
|
||||
let src = format!("<ASCII-WIN>\r\n<ParaStyle:Body>A{t}B");
|
||||
assert_eq!(import(src.as_bytes()).unwrap().story.text, "AB", "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A size does not have to be non-finite to be nonsense: a file can hold `1e300`, `0` or a
|
||||
/// negative, and tagged text took all three and set them on the text (#1593). They are now
|
||||
/// dropped, as RTF and DOCX already dropped them, so the text keeps its style's size.
|
||||
|
||||
@@ -36,7 +36,7 @@ Fidelity: **unmeasured** unless stated.
|
||||
| Single-line Composer | greedy | greedy with the same ranges | ✓ | |
|
||||
| World-Ready composers | Paragraph / Single-line World-Ready | RTL, Arabic and bidi in the main composer | ~ | Indic scripts (Devanagari etc.) shape through harfrust but are untested |
|
||||
| Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab justifies after the tab (#284); set per paragraph in Type › Justification… and the Paragraph panel menu (#531) |
|
||||
| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no), Greek (hyph-el-monoton) and German 2006 (hyph-de-1996); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame | ~ | other languages don't hyphenate (#87 reported "doesn't work") |
|
||||
| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no), Greek (hyph-el-monoton) and German 2006 (hyph-de-1996); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame; discretionary U+00AD breaks with both composers regardless of automatic hyphenation and replaces pattern points within its word (#1955) | ~ | other languages don't hyphenate (#87 reported "doesn't work") |
|
||||
| Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | |
|
||||
| Kerning | Metrics, Optical, manual, none | Metrics, Optical (outline-based: pairs pulled towards the font's stem gap, `crates/compose/src/optical.rs`), manual, none | ~ | Optical is an approximation of Adobe's algorithm (#181), not a match: imported display text can still break differently |
|
||||
| Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | |
|
||||
|
||||
Reference in New Issue
Block a user