Skip to content
Open
147 changes: 145 additions & 2 deletions crates/engine/src/cmd/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ pub use painter::Painted;
pub(crate) use painter::apply_painted;
use painter::{font_add, font_rescan, painter_apply, painter_pick, state};
use paragraph::{
align, align_to, anchor, autofit, bullets, columns, direction, level, line_spacing, margins, numbering, paragraph, paragraph_dialog, rtl, wrap,
align, align_to, anchor, autofit, bullets, columns, direction, east_asian_line_break, hanging_punctuation, latin_line_break, level, line_spacing,
margins, numbering, paragraph, paragraph_dialog, rtl, wrap,
};
use ruler::{ruler, tabs};
use text_effects::{case, text_outline, text_shadow, text_warp};
Expand Down Expand Up @@ -161,6 +162,33 @@ pub fn specs() -> Vec<CommandSpec> {
has_text_or_shapes,
rtl
),
cmd!(
"format.eastAsianLineBreak",
"East Asian Line Breaking",
[],
None,
"{on?: bool} (禁則処理: PowerPoint's `eaLnBrk`)",
has_text_or_shapes,
east_asian_line_break
),
cmd!(
"format.hangingPunctuation",
"Hang Punctuation",
[],
None,
"{on?: bool} (句読点のぶら下げ: PowerPoint's `hangingPunct`)",
has_text_or_shapes,
hanging_punctuation
),
cmd!(
"format.latinLineBreak",
"Allow Latin Text to Wrap Mid-word",
[],
None,
"{on?: bool} (英単語の途中で改行を許可: PowerPoint's `latinLnBrk`)",
has_text_or_shapes,
latin_line_break
),
cmd!("format.alignLeft", "Align Left", ["Home", "Paragraph"], Some("Cmd+L"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Left)),
cmd!("format.alignCenter", "Center", ["Home", "Paragraph"], Some("Cmd+E"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Center)),
cmd!("format.alignRight", "Align Right", ["Home", "Paragraph"], Some("Cmd+R"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Right)),
Expand Down Expand Up @@ -209,7 +237,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paragraph…",
["Home", "Paragraph"],
None,
"{align?: left|center|right|justify|justLow|distributed, indentLeft?: pt, indentRight?: pt, special?: none|first|hanging, specialBy?: pt, indentFirst?: pt, spaceBefore?: pt, spaceAfter?: pt, spaceBeforeKind?: pts|pct, spaceAfterKind?: pts|pct, lineSpacing?: lines, lineSpacingPt?: pt} — with none of these, opens the Paragraph dialog",
"{align?: left|center|right|justify|justLow|distributed, indentLeft?: pt, indentRight?: pt, special?: none|first|hanging, specialBy?: pt, indentFirst?: pt, spaceBefore?: pt, spaceAfter?: pt, spaceBeforeKind?: pts|pct, spaceAfterKind?: pts|pct, lineSpacing?: lines, lineSpacingPt?: pt, eastAsianLineBreak?: bool, hangingPunctuation?: bool, latinLineBreak?: bool} — with none of these, opens the Paragraph dialog",
has_text_or_shapes,
paragraph_dialog
),
Expand Down Expand Up @@ -592,4 +620,119 @@ mod tests {
assert!(s.execute("format.tabs", &json!({"set": "nope"})).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), n);
}

/// The three Asian typography paragraph properties as `format.*` sets them.
const ASIAN_PARA_COMMANDS: [(&str, fn(&ParaProps) -> Option<bool>); 3] = [
("format.eastAsianLineBreak", |p| p.east_asian_line_break),
("format.hangingPunctuation", |p| p.hanging_punct),
("format.latinLineBreak", |p| p.latin_line_break),
];

#[test]
fn asian_paragraph_commands_set_clear_and_toggle_the_selection() {
for (cmd, field) in ASIAN_PARA_COMMANDS {
let mut s = shape();
assert_eq!(field(&para(&s)), None, "{cmd} starts unset");
// Absent `eaLnBrk` means on, so its first bare toggle turns the rule off; the other
// two start off and their first toggle turns them on.
s.execute(cmd, &json!({})).unwrap();
let first = if cmd == "format.eastAsianLineBreak" { Some(false) } else { Some(true) };
assert_eq!(field(&para(&s)), first, "{cmd} toggles from the effective default");
let mut s = shape();
s.execute(cmd, &json!({"on": true})).unwrap();
assert_eq!(field(&para(&s)), Some(true), "{cmd} on");
s.execute(cmd, &json!({"on": false})).unwrap();
assert_eq!(field(&para(&s)), Some(false), "{cmd} off");
// No `on`: toggle the value the caret's paragraph shows. A hostile `on` is ignored
// and toggles too, the way every other toggle command behaves.
for hostile in [json!({}), json!({"on": "yes"})] {
s.execute(cmd, &hostile).unwrap();
assert_eq!(field(&para(&s)), Some(true), "{cmd} toggled on");
s.execute(cmd, &hostile).unwrap();
assert_eq!(field(&para(&s)), Some(false), "{cmd} toggled off");
}
// Undo takes the command back to no explicit value.
let mut s = shape();
s.execute(cmd, &json!({"on": true})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(field(&para(&s)), None, "undo clears what {cmd} set");
// The command touched only its own property.
let mut s = shape();
s.execute(cmd, &json!({"on": true})).unwrap();
let p = para(&s);
assert_eq!(field(&p), Some(true));
let set = [p.east_asian_line_break, p.hanging_punct, p.latin_line_break].iter().filter(|v| v.is_some()).count();
assert_eq!(set, 1, "{cmd} left the other two properties alone");
}
}

#[test]
fn asian_paragraph_commands_reach_every_selected_paragraph() {
let mut s = Session::with_new();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 240.0, 80.0], "text": "一つ目\n二つ目"})).unwrap();
let id = s.doc().unwrap().selected_shapes().first().map(|sh| sh.id.0).unwrap();
s.execute("text.edit", &json!({"id": id})).unwrap();
s.execute("text.selectAll", &json!({})).unwrap();
s.execute("format.hangingPunctuation", &json!({"on": true})).unwrap();
let props: Vec<Option<bool>> = s
.doc()
.unwrap()
.shape(deckcraft_model::ShapeId(id))
.and_then(|sh| sh.text.as_ref())
.map(|t| t.paragraphs.iter().map(|p| p.props.hanging_punct).collect())
.unwrap_or_default();
assert_eq!(props, vec![Some(true), Some(true)], "both selected paragraphs");
}

#[test]
fn asian_paragraph_options_survive_a_save_and_reopen() {
let mut s = shape();
for (cmd, _) in ASIAN_PARA_COMMANDS {
s.execute(cmd, &json!({"on": true})).unwrap();
}
let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
let reopened = deckcraft_pptx::import(&crate::cmd::base64_decode(&saved).unwrap()).unwrap();
let p = reopened.slides[0]
.shapes
.iter()
.filter_map(|sh| sh.text.as_ref())
.flat_map(|t| t.paragraphs.iter())
.find(|p| p.text() == "Hello")
.map(|p| p.props.clone())
.unwrap();
assert_eq!(p.east_asian_line_break, Some(true));
assert_eq!(p.hanging_punct, Some(true));
assert_eq!(p.latin_line_break, Some(true));
}

#[test]
fn paragraph_dialog_sets_asian_options_in_the_same_undo() {
let mut s = shape();
let before = s.doc().unwrap().history.undo.len();
s.execute("format.paragraphDialog", &json!({"align": "justify", "hangingPunctuation": true, "latinLineBreak": true})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), before + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Paragraph…");
let p = para(&s);
assert_eq!(p.align, Some(Align::Justify));
assert_eq!(p.hanging_punct, Some(true));
assert_eq!(p.latin_line_break, Some(true));
assert_eq!(p.east_asian_line_break, None, "an absent key leaves its property alone");
s.execute("edit.undo", &json!({})).unwrap();
let p = para(&s);
assert_eq!(p.hanging_punct, None);
assert_eq!(p.latin_line_break, None);
assert_eq!(p.align, Some(Align::Center), "undo restores the alignment shape.insert wrote");
}

#[test]
fn paragraph_dialog_rejects_a_hostile_asian_option_without_editing() {
let mut s = shape();
let n = s.doc().unwrap().history.undo.len();
assert!(s.execute("format.paragraphDialog", &json!({"hangingPunctuation": 1})).is_err());
assert!(s.execute("format.paragraphDialog", &json!({"eastAsianLineBreak": "on"})).is_err());
assert!(s.execute("format.paragraphDialog", &json!({"latinLineBreak": null})).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), n);
let p = para(&s);
assert!(p.east_asian_line_break.is_none() && p.hanging_punct.is_none() && p.latin_line_break.is_none());
}
}
62 changes: 60 additions & 2 deletions crates/engine/src/cmd/format/paragraph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,21 @@ pub(super) fn paragraph(s: &mut Session, p: &Value) -> Result<Value> {
})
}

const PARA_DIALOG_KEYS: &[&str] =
&["align", "indentLeft", "indentRight", "indentFirst", "special", "specialBy", "spaceBefore", "spaceAfter", "lineSpacing", "lineSpacingPt"];
const PARA_DIALOG_KEYS: &[&str] = &[
"align",
"indentLeft",
"indentRight",
"indentFirst",
"special",
"specialBy",
"spaceBefore",
"spaceAfter",
"lineSpacing",
"lineSpacingPt",
"eastAsianLineBreak",
"hangingPunctuation",
"latinLineBreak",
];

fn para_dialog_has_fields(p: &Value) -> bool {
PARA_DIALOG_KEYS.iter().any(|k| p.get(*k).is_some())
Expand All @@ -162,6 +175,14 @@ pub(super) fn finite_key(p: &Value, cmd: &str, key: &str) -> Result<Option<f64>>
}
}

fn bool_key(p: &Value, cmd: &str, key: &str) -> Result<Option<bool>> {
match p.get(key) {
None => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
Some(_) => Err(bad(cmd, format!("`{key}` must be true or false"))),
}
}

pub(super) fn clamp_pt(v: f64) -> f64 {
v.clamp(0.0, 1584.0)
}
Expand Down Expand Up @@ -227,6 +248,11 @@ fn paragraph_dialog_params(s: &Session) -> Value {
"spaceAfterKind": after_kind,
"lineKind": line_kind,
"lineValue": line_value,
// The layout treats an absent `eaLnBrk` as on (PowerPoint's default), so the checkbox
// shows the effective value; a box that opened unchecked would write Some(false) on OK.
"eastAsianLineBreak": pp.east_asian_line_break.unwrap_or(true),
"hangingPunctuation": pp.hanging_punct.unwrap_or(false),
"latinLineBreak": pp.latin_line_break.unwrap_or(false),
"tabs": tabs,
"defaultTab": pp.default_tab.unwrap_or(72.0),
})
Expand Down Expand Up @@ -274,6 +300,9 @@ pub(super) fn paragraph_dialog(s: &mut Session, p: &Value) -> Result<Value> {
}
let line_mult = finite_key(p, CMD, "lineSpacing")?.map(|v| v.clamp(0.0, 9.99));
let line_pt = finite_key(p, CMD, "lineSpacingPt")?.map(clamp_pt);
let east_asian_break = bool_key(p, CMD, "eastAsianLineBreak")?;
let hanging = bool_key(p, CMD, "hangingPunctuation")?;
let latin_break = bool_key(p, CMD, "latinLineBreak")?;
format_para(s, &move |para| {
if align.is_some() {
para.props.align = align;
Expand All @@ -299,9 +328,38 @@ pub(super) fn paragraph_dialog(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = line_pt {
para.props.line_spacing = Some(Spacing::Pts(v));
}
if let Some(v) = east_asian_break {
para.props.east_asian_line_break = Some(v);
}
if let Some(v) = hanging {
para.props.hanging_punct = Some(v);
}
if let Some(v) = latin_break {
para.props.latin_line_break = Some(v);
}
})
}

/// 禁則処理 (`eaLnBrk`): East Asian line breaking rules (small kana, the prolonged sound mark and
/// iteration marks never start a line).
pub(super) fn east_asian_line_break(s: &mut Session, p: &Value) -> Result<Value> {
// Absent means on in the layout, so the first bare toggle turns it off.
let on = on_param(p, current_para(s).east_asian_line_break.or(Some(true)));
format_para(s, &move |para| para.props.east_asian_line_break = Some(on))
}

/// 句読点のぶら下げ (`hangingPunct`): closing East Asian punctuation may hang past the end of a line.
pub(super) fn hanging_punctuation(s: &mut Session, p: &Value) -> Result<Value> {
let on = on_param(p, current_para(s).hanging_punct);
format_para(s, &move |para| para.props.hanging_punct = Some(on))
}

/// 英単語の途中で改行を許可 (`latinLnBrk`): Latin words may break inside, after `/` or `\`.
pub(super) fn latin_line_break(s: &mut Session, p: &Value) -> Result<Value> {
let on = on_param(p, current_para(s).latin_line_break);
format_para(s, &move |para| para.props.latin_line_break = Some(on))
}

pub(super) fn tab_align(s: &str) -> Option<&'static str> {
Some(match s {
"l" | "left" => "l",
Expand Down
64 changes: 54 additions & 10 deletions crates/text/src/breaking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,11 @@ fn is_cj_nonstarter(c: char) -> bool {
}

/// Break opportunities: allowed[i] = a line may start at char i. Unicode line breaking, then
/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, and
/// Korean breaks only between words.
pub(crate) fn break_opportunities(text: &str, latin_break: bool) -> (Vec<bool>, Vec<bool>) {
/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, Korean
/// breaks only between words, and without `eaLnBrk` (East Asian line breaking, the default) the
/// strict Japanese rule that keeps a small kana or the prolonged sound mark off the start of a
/// line is relaxed.
pub(crate) fn break_opportunities(text: &str, latin_break: bool, east_asian_break: bool) -> (Vec<bool>, Vec<bool>) {
let n = text.chars().count();
let mut allowed = vec![false; n + 1];
let mut mandatory = vec![false; n + 1];
Expand All @@ -107,20 +109,57 @@ pub(crate) fn break_opportunities(text: &str, latin_break: bool) -> (Vec<bool>,
}
}
let chars: Vec<char> = text.chars().collect();
// Relaxing 禁則 (`eaLnBrk="0"`) lifts only the Unicode rule that keeps a non-starter off the
// start of a line (LB21, and LB16 before a non-starter), never a rule that binds the position
// for another reason: an opening bracket still ends no line (LB14), and U+2060 or a no-break
// space still holds (LB11, LB12). Rerun the algorithm on a copy where each non-starter is an
// ordinary kana — one char for one char, so char indices map back — and accept a break before
// a non-starter only where that relaxed run allows it.
let mut relaxed_allowed = vec![false; n + 1];
if !east_asian_break && chars.iter().any(|c| is_cj_nonstarter(*c)) {
let relaxed: String = chars.iter().map(|c| if is_cj_nonstarter(*c) { 'あ' } else { *c }).collect();
let mut char_of_byte = std::collections::HashMap::new();
for (ci, (b, _)) in relaxed.char_indices().enumerate() {
char_of_byte.insert(b, ci);
}
char_of_byte.insert(relaxed.len(), n);
for (b, _) in unicode_linebreak::linebreaks(&relaxed) {
if let Some(&ci) = char_of_byte.get(&b)
&& let Some(a) = relaxed_allowed.get_mut(ci)
{
*a = true;
}
}
}
for (i, w) in chars.windows(2).enumerate() {
let (prev, next) = (w[0], w[1]);
// PowerPoint never breaks after a zero-width space, nor before an em dash (only after it).
let keep = if (is_hangul(prev) && is_hangul(next)) || prev == '\u{200B}' || next == '\u{2014}' || is_cj_nonstarter(next) {
false
} else {
latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace()
};
let other_prohibition = (is_hangul(prev) && is_hangul(next)) || prev == '\u{200B}' || next == '\u{2014}';
let latin_allowed = latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace();
let keep = if other_prohibition || (east_asian_break && is_cj_nonstarter(next)) { false } else { latin_allowed };
let mandatory_here = mandatory.get(i + 1).copied().unwrap_or(false);
if !keep
&& !mandatory.get(i + 1).copied().unwrap_or(false)
&& !mandatory_here
&& let Some(a) = allowed.get_mut(i + 1)
{
*a = false;
}
// With East Asian line breaking off (`eaLnBrk="0"`, 禁則処理なし) a line may start with a
// small kana or the prolonged sound mark. Unicode offers no break before them at all, so
// relaxing 禁則 means adding the opportunity, not keeping one — but only where no *other*
// rule forbids the break (after a zero-width space, before an em dash, or inside a Latin
// word without `latinLnBrk`) and where the relaxed run above still allows it (an opening
// bracket, U+2060 or a no-break space keeps its Unicode prohibition).
if !east_asian_break
&& is_cj_nonstarter(next)
&& !other_prohibition
&& latin_allowed
&& !mandatory_here
&& relaxed_allowed.get(i + 1).copied().unwrap_or(false)
&& let Some(a) = allowed.get_mut(i + 1)
{
*a = true;
}
}
(allowed, mandatory)
}
Expand All @@ -143,7 +182,12 @@ pub(crate) fn break_lines(
x0_rest: f64,
) -> Vec<RawLine> {
let text: String = p.cells.iter().map(|c| c.ch).collect();
let (allowed, _) = break_opportunities(&text, p.props.latin_line_break.unwrap_or(false));
let (allowed, _) = break_opportunities(
&text,
p.props.latin_line_break.unwrap_or(false),
// `eaLnBrk` absent means on (PowerPoint's default): only an explicit off relaxes 禁則.
p.props.east_asian_line_break.unwrap_or(true),
);
let hang = p.props.hanging_punct.unwrap_or(false);
let n = p.cells.len();
let mut lines = vec![];
Expand Down
Loading