Merge feat/style-sections-cjk: CJK sections in Paragraph and Character Style Options

Conflict: crates/engine/src/cmd/style.rs: both params notes in the paragraph style edit spec (startAt null, kinsoku null); create_para with next's set_json_over, continue_numbering and the branch's explicit_default_kinsoku; edit_para calls both helpers; both tests
Conflict: crates/ui-egui/src/dialogs.rs: next's section arms (advanced to bullets) and the branch's CJK section arm; next's paragraph helpers and paragraph_local before the branch's section_list (cjk flag, scrolling list; doc comment stays removed as on next)
The branch's "Lines:" row in the shared i18n table is dropped: next already has the key (drop caps).
This commit is contained in:
aiongg
2026-10-10 16:27:26 +00:00
5 changed files with 763 additions and 6 deletions
+27 -1
View File
@@ -95,7 +95,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paragraph Style Options…",
[],
None,
"{name, rename?, basedOn?, nextStyle?, para?: {… ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering}, chars?: {…}}",
"{name, rename?, basedOn?, nextStyle?, para?: {… ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules)}, chars?: {…}}",
has_doc,
edit_para
),
@@ -440,6 +440,7 @@ fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string();
let mut para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &designcraft_doc::ParaProps::default()))?;
continue_numbering(p.get("para"), &mut para);
explicit_default_kinsoku(&mut para, p.get("para"));
let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false)
&& let Ok(cur) = s.execute("type.selectionAttrs", &json!({}))
@@ -470,6 +471,14 @@ fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// `kinsoku: null` sets the composer's default kinsoku rules, also over a Based On style's set.
/// For the other attributes `null` leaves the style's value as it is.
fn explicit_default_kinsoku(para: &mut ParaAttrs, given: Option<&Value>) {
if given.and_then(|o| o.get("kinsoku")).is_some_and(Value::is_null) {
para.kinsoku = Some(None);
}
}
#[allow(non_snake_case)]
fn ParaProps_to_attrs(p: &designcraft_doc::ParaProps) -> ParaAttrs {
designcraft_doc::ParaProps::common([p])
@@ -494,6 +503,7 @@ fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
let (current, _) = d.styles.resolve_para_style(&name);
let mut para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &current))?;
continue_numbering(p.get("para"), &mut para);
explicit_default_kinsoku(&mut para, p.get("para"));
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
st.para.merge(&para);
st.chars.merge(&chars);
@@ -1216,6 +1226,22 @@ mod color_tests {
assert_eq!(s.doc().unwrap().doc.styles.para("Fresh").unwrap().para.start_at, Some(None));
}
#[test]
fn null_kinsoku_sets_the_default_rules_over_a_based_on_set() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let hard = serde_json::to_value(designcraft_doc::cjk::Kinsoku::named("HardKinsoku").unwrap()).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Base", "para": {"kinsoku": hard}})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Body", "basedOn": "Base", "para": {"kinsoku": hard}})).unwrap();
s.execute("style.paragraph.edit", &json!({"name": "Body", "para": {"kinsoku": null}})).unwrap();
let styles = s.doc().unwrap().doc.styles.clone();
assert_eq!(styles.para("Body").unwrap().para.kinsoku, Some(None));
assert_eq!(styles.resolve_para_style("Body").0.kinsoku, None, "Base's set no longer applies");
// An edit without the attribute keeps it.
s.execute("style.paragraph.edit", &json!({"name": "Body", "para": {"rensuuji": false}})).unwrap();
assert_eq!(s.doc().unwrap().doc.styles.para("Body").unwrap().para.kinsoku, Some(None));
}
#[test]
fn new_character_style_checks_its_based_on() {
let mut s = Session::new();
+17 -5
View File
@@ -8,6 +8,8 @@ use serde_json::{Map, Value, json};
use crate::DesignApp;
use crate::theme::semibold;
mod style_cjk;
#[derive(Clone, Debug, Serialize)]
pub struct Dialog {
pub id: String,
@@ -2280,6 +2282,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
&app.ui.language,
ui,
d,
crate::cjk_features(app),
&[
("general", "General"),
("chars", "Basic Character Formats"),
@@ -2708,6 +2711,9 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
ListUi { char_styles: &char_styles, families: &families, fonts: &fonts, scope, family: &cp.font_family, style: &cp.font_style };
bullets_and_numbering(&app.ui.language, ui, d, &pv, &doc, &lu);
}
s if style_cjk::is_section(s) => {
style_cjk::section(&app.ui.language, ui, d, s, &CharFields { base: &cv, sparse: false }, Some(&pv), &doc)
}
_ => {
egui::Grid::new("psg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Style Name:"));
@@ -3572,14 +3578,18 @@ fn paragraph_local(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
}
fn section_list(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, sections: &[(&str, &str)]) {
fn section_list(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cjk: bool, sections: &[(&str, &str)]) {
let sections = style_cjk::sections(cjk, d.id == "paragraphStyleOptions", sections);
ui.vertical(|ui| {
ui.set_width(170.0);
for (id, label) in sections {
if ui.selectable_label(d.s("section") == *id, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() {
d.fields.insert("section".into(), json!(id));
// The dialog has a fixed height; a long list scrolls.
egui::ScrollArea::vertical().id_salt("style_sections").auto_shrink([false, true]).show(ui, |ui| {
for (id, label) in &sections {
if ui.selectable_label(d.s("section") == *id, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() {
d.fields.insert("section".into(), json!(id));
}
}
}
});
});
}
@@ -3652,6 +3662,7 @@ fn character_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
&lang,
ui,
d,
crate::cjk_features(app),
&[
("general", "General"),
("chars", "Basic Character Formats"),
@@ -3670,6 +3681,7 @@ fn character_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
"openType" => open_type_features(&lang, ui, d, &cf),
"underline" => line_options(&lang, ui, d, &cf, &doc, "underline"),
"strikethrough" => line_options(&lang, ui, d, &cf, &doc, "strikethrough"),
s if style_cjk::is_section(s) => style_cjk::section(&lang, ui, d, s, &cf, None, &doc),
_ => {
egui::Grid::new("csg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, crate::i18n::tr(&lang, "Style Name:"));
+570
View File
@@ -0,0 +1,570 @@
//! The CJK sections of Paragraph Style Options and Character Style Options, listed while Show CJK
//! Features is on ([`crate::cjk_features`]): Tate-chu-yoko, Kenten, Japanese Composition, Grid
//! and Warichu settings. They edit `c.<attr>` and `p.<attr>` fields like the other sections, and
//! OK applies them with the dialog's style edit command.
use designcraft_doc::cjk::Kinsoku;
use serde_json::{Value, json};
use super::{CharFields, Dialog, char_check, char_choice, char_number};
struct Section {
id: &'static str,
label: &'static str,
/// Absent from Character Style Options.
para_only: bool,
/// Listed after Export Tagging.
after_export: bool,
}
const SECTIONS: &[Section] = &[
Section { id: "cjk.tcy", label: "Tate-chu-yoko Settings", para_only: false, after_export: false },
Section { id: "cjk.kenten", label: "Kenten Settings", para_only: false, after_export: false },
Section { id: "cjk.composition", label: "Japanese Composition Settings", para_only: true, after_export: false },
Section { id: "cjk.grid", label: "Grid Settings", para_only: true, after_export: false },
Section { id: "cjk.warichu", label: "Warichu Settings", para_only: false, after_export: true },
];
/// The section list of a style options dialog: `base` with the CJK sections when `cjk` is on.
/// They go before Export Tagging, except Warichu Settings, which follows it; without Export
/// Tagging they all go last.
pub(super) fn sections<'a>(cjk: bool, para: bool, base: &[(&'a str, &'a str)]) -> Vec<(&'a str, &'a str)> {
if !cjk {
return base.to_vec();
}
let shown = move |after: bool| SECTIONS.iter().filter(move |s| (para || !s.para_only) && s.after_export == after).map(|s| (s.id, s.label));
let mut out = Vec::with_capacity(base.len() + SECTIONS.len());
let mut placed = false;
for s in base {
if s.0 == "export" && !placed {
out.extend(shown(false));
out.push(*s);
out.extend(shown(true));
placed = true;
} else {
out.push(*s);
}
}
if !placed {
out.extend(shown(false));
out.extend(shown(true));
}
out
}
pub(super) fn is_section(id: &str) -> bool {
SECTIONS.iter().any(|s| s.id == id)
}
/// Draws CJK section `id`. `para` holds the paragraph style's resolved paragraph attributes; it
/// is `None` in Character Style Options, which have no paragraph-only sections.
pub(super) fn section(
lang: &str,
ui: &mut egui::Ui,
d: &mut Dialog,
id: &str,
cf: &CharFields,
para: Option<&Value>,
doc: &designcraft_doc::Document,
) {
match (id, para) {
("cjk.tcy", _) => tate_chu_yoko(lang, ui, d, cf),
("cjk.kenten", _) => kenten(lang, ui, d, cf),
("cjk.warichu", _) => warichu(lang, ui, d, cf),
("cjk.composition", Some(pv)) => japanese_composition(lang, ui, d, cf, pv, doc),
("cjk.grid", Some(pv)) => grid_settings(lang, ui, d, pv),
_ => {}
}
}
/// Tate-chu-yoko Settings: the switch and the run's offsets.
fn tate_chu_yoko(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields) {
char_check(ui, d, cf, "tateChuYoko", crate::i18n::tr(lang, "Tate-Chu-Yoko"));
ui.add_space(4.0);
egui::Grid::new("cjktcy").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Up/Down Position:"));
char_number(ui, d, cf, "tateChuYokoYOffset", " pt", 1.0, (-1000.0, 1000.0));
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(lang, "Left/Right Position:"));
char_number(ui, d, cf, "tateChuYokoXOffset", " pt", 1.0, (-1000.0, 1000.0));
ui.end_row();
});
}
/// The preset kenten marks: (label, character). The sesame dot is the empty character, which the
/// composer draws as U+FE45 and IDML export writes as `KentenSesameDot`.
pub(super) const KENTEN: &[(&str, &str)] = &[
("Sesame Dot", ""),
("White Sesame Dot", "\u{FE46}"),
("Bullseye", "\u{25C9}"),
("Black Circle", "\u{25CF}"),
("Small Black Circle", "\u{2022}"),
("Double Circle", "\u{25CE}"),
("Black Triangle", "\u{25B2}"),
("White Triangle", "\u{25B3}"),
("White Circle", "\u{25CB}"),
("Small White Circle", "\u{25E6}"),
];
/// Dialog-only: Custom was picked, so the Character field is editable even while it holds a
/// preset mark.
const KENTEN_CUSTOM: &str = "cjk.kentenCustom";
fn kenten_preset(character: &str) -> Option<&'static (&'static str, &'static str)> {
let character = if character == "\u{FE45}" { "" } else { character };
KENTEN.iter().find(|k| k.1 == character)
}
/// Kenten Type picks: `None` switches kenten off, `Some(mark)` sets that mark.
pub(super) fn pick_kenten(d: &mut Dialog, cf: &CharFields, mark: Option<&str>) {
d.fields.remove(KENTEN_CUSTOM);
match mark {
Some(m) => {
cf.set(d, "kenten", json!(true));
cf.set(d, "kentenCharacter", json!(m));
}
None => {
cf.set(d, "kenten", json!(false));
// IDML export writes any stored mark as a custom kenten, even with kenten off.
if cf.get(d, "kentenCharacter").as_str().is_some_and(|c| !c.is_empty()) {
cf.set(d, "kentenCharacter", json!(""));
}
}
}
}
/// Kenten Settings: the mark (a preset or a custom character).
fn kenten(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields) {
let on = cf.get(d, "kenten").as_bool();
let character = cf.get(d, "kentenCharacter").as_str().unwrap_or("").to_string();
let preset = kenten_preset(&character);
let custom = on == Some(true) && (d.b(KENTEN_CUSTOM) || preset.is_none());
let shown = match on {
None => String::new(),
Some(false) => crate::i18n::tr(lang, "None").to_string(),
Some(true) if custom => crate::i18n::tr(lang, "Custom").to_string(),
Some(true) => preset.map_or(String::new(), |p| crate::i18n::tr(lang, p.0).to_string()),
};
egui::Grid::new("cjkkenten").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Kenten Type:"));
egui::ComboBox::from_id_salt("cjkkententype").selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| {
if cf.sparse && ui.selectable_label(on.is_none(), " ").clicked() {
d.fields.remove(KENTEN_CUSTOM);
cf.set(d, "kenten", Value::Null);
cf.set(d, "kentenCharacter", Value::Null);
}
if ui.selectable_label(on == Some(false), crate::rtl::widget(ui, crate::i18n::tr(lang, "None"))).clicked() {
pick_kenten(d, cf, None);
}
for (label, mark) in KENTEN {
let selected = on == Some(true) && !custom && preset.is_some_and(|p| p.1 == *mark);
if ui.selectable_label(selected, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() {
pick_kenten(d, cf, Some(*mark));
}
}
if ui.selectable_label(custom, crate::rtl::widget(ui, crate::i18n::tr(lang, "Custom"))).clicked() {
d.fields.insert(KENTEN_CUSTOM.into(), json!(true));
cf.set(d, "kenten", json!(true));
}
});
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(lang, "Character:"));
let mut text = if custom { character.clone() } else { String::new() };
if ui.add_enabled(custom, egui::TextEdit::singleline(&mut text).desired_width(40.0)).changed() {
// One mark: the character typed last replaces the one before it.
let mark: String = text.chars().last().map(String::from).unwrap_or_default();
cf.set(d, "kentenCharacter", json!(mark));
}
ui.end_row();
});
}
/// A whole-number attribute (stored as an integer: the model's fields are `u32`).
fn char_count(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, range: (f64, f64)) {
let v = cf.get(d, key).as_f64();
let field = crate::widgets::NumField::number(key, v, "", 0).width(80.0).range(range.0, range.1);
let edit = if cf.sparse { field.show_or_clear(ui) } else { field.show(ui).map(Some) };
if let Some(n) = edit {
cf.set(d, key, n.map_or(Value::Null, |n| json!(n.round().max(range.0).min(range.1) as u64)));
}
}
/// Warichu Settings: the switch, lines, size, spacing, alignment and line break minimums.
fn warichu(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields) {
char_check(ui, d, cf, "warichu", crate::i18n::tr(lang, "Warichu"));
ui.add_space(4.0);
egui::Grid::new("cjkwarichu").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Lines:"));
char_count(ui, d, cf, "warichuLines", (2.0, 16.0));
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(lang, "Warichu Size:"));
char_number(ui, d, cf, "warichuSize", "%", 1.0, (1.0, 1000.0));
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(lang, "Line Spacing:"));
char_number(ui, d, cf, "warichuLineSpacing", " pt", 1.0, (-1000.0, 1000.0));
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(lang, "Alignment:"));
char_choice(
lang,
ui,
d,
cf,
"warichuAlignment",
&[
(json!("auto"), "Auto"),
(json!("left"), "Left"),
(json!("center"), "Center"),
(json!("right"), "Right"),
(json!("justify"), "Justify"),
],
);
ui.end_row();
});
ui.add_space(6.0);
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Line Break Options")).font(crate::theme::semibold(12.0)));
egui::Grid::new("cjkwarichubreak").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Characters Before Break:"));
char_count(ui, d, cf, "warichuCharsBeforeBreak", (0.0, 100.0));
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(lang, "Characters After Break:"));
char_count(ui, d, cf, "warichuCharsAfterBreak", (0.0, 100.0));
ui.end_row();
});
}
/// A paragraph attribute as shown: the dialog's `p.<key>` edit, else the resolved value.
fn para_value(d: &Dialog, pv: &Value, key: &str) -> Value {
d.fields.get(&format!("p.{key}")).cloned().unwrap_or_else(|| pv.get(key).cloned().unwrap_or(Value::Null))
}
/// A paragraph attribute from `opts` (value, label); `current` is the value shown.
fn para_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, current: &str, opts: &[(&str, &str)]) {
let shown = opts.iter().find(|o| o.0 == current).map_or("", |o| crate::i18n::tr(lang, o.1));
egui::ComboBox::from_id_salt(("cjkp", key)).selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| {
for (v, label) in opts {
if ui.selectable_label(*v == current, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() {
d.fields.insert(format!("p.{key}"), json!(v));
}
}
});
}
fn para_check(ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, label: &str) {
let mut on = para_value(d, pv, key).as_bool().unwrap_or(false);
if ui.checkbox(&mut on, crate::rtl::widget(ui, label)).changed() {
d.fields.insert(format!("p.{key}"), json!(on));
}
}
/// The built-in kinsoku sets ([`Kinsoku::named`]) and their labels.
const BUILT_IN_KINSOKU: &[(&str, &str)] = &[
("HardKinsoku", "Hard Kinsoku"),
("SoftKinsoku", "Soft Kinsoku"),
("SimplifiedChineseKinsoku", "Simplified Chinese Kinsoku"),
("TraditionalChineseKinsoku", "Traditional Chinese Kinsoku"),
("KoreanKinsoku", "Korean Kinsoku"),
];
/// The Kinsoku Set menu: no kinsoku, the built-in sets, then every other named set the document's
/// paragraph styles and paragraphs use (the model keeps each set by value, not in a list).
pub(super) fn kinsoku_sets(doc: &designcraft_doc::Document) -> Vec<Kinsoku> {
let mut sets = vec![Kinsoku::default()];
sets.extend(BUILT_IN_KINSOKU.iter().filter_map(|(name, _)| Kinsoku::named(name)));
let used = doc.styles.paragraph.iter().map(|s| &s.para.kinsoku).chain(doc.stories.values().flat_map(|s| s.paras.iter().map(|p| &p.para.kinsoku)));
for k in used.flatten().flatten() {
if !k.name.is_empty() && !sets.iter().any(|s| s.name == k.name) {
sets.push(k.clone());
}
}
sets
}
fn kinsoku_label(lang: &str, name: &str) -> String {
if name.is_empty() {
return crate::i18n::tr(lang, "None").to_string();
}
BUILT_IN_KINSOKU.iter().find(|k| k.0 == name).map_or_else(|| name.to_string(), |k| crate::i18n::tr(lang, k.1).to_string())
}
/// IDML's names for "no mojikumi set".
fn mojikumi_value(v: &str) -> &str {
if matches!(v, "Nothing" | "None") { "" } else { v }
}
/// The Mojikumi Set menu as (value, label): none (empty label), the document's sets, and the
/// `current` value when it names a set the document doesn't define (kept as imported).
pub(super) fn mojikumi_sets(doc: &designcraft_doc::Document, current: &str) -> Vec<(String, String)> {
let mut out = vec![(String::new(), String::new())];
for t in &doc.styles.mojikumi_tables {
let v = format!("MojikumiTable/{}", t.name);
if !out.iter().any(|o| o.0 == v) {
out.push((v, t.name.clone()));
}
}
let current = mojikumi_value(current);
if !current.is_empty() && !out.iter().any(|o| o.0 == current) {
out.push((current.to_string(), current.trim_start_matches("MojikumiTable/").trim_start_matches("$ID/").to_string()));
}
out
}
/// Japanese Composition Settings: kinsoku, hanging, bunri-kinshi, mojikumi, the leading model,
/// rensuuji and line-end ideographic spaces.
fn japanese_composition(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, pv: &Value, doc: &designcraft_doc::Document) {
egui::Grid::new("cjkcomp").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Kinsoku Set:"));
let sets = kinsoku_sets(doc);
// `null`: no set given, so the composer's default rules apply.
let name = para_value(d, pv, "kinsoku").get("name").and_then(Value::as_str).map(str::to_string);
let shown = name.as_deref().map_or_else(|| crate::i18n::tr(lang, "Default Rules").to_string(), |n| kinsoku_label(lang, n));
egui::ComboBox::from_id_salt("cjkkinsoku").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| {
// `style.paragraph.edit` takes `null` as the default rules, also over a Based On set.
if ui.selectable_label(name.is_none(), crate::rtl::widget(ui, crate::i18n::tr(lang, "Default Rules"))).clicked() {
d.fields.insert("p.kinsoku".into(), Value::Null);
}
for k in &sets {
if ui.selectable_label(name.as_deref() == Some(k.name.as_str()), crate::rtl::widget(ui, kinsoku_label(lang, &k.name))).clicked() {
if let Ok(v) = serde_json::to_value(k) {
d.fields.insert("p.kinsoku".into(), v);
}
}
}
});
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(lang, "Kinsoku Type:"));
let kind = para_value(d, pv, "kinsokuType");
let kind = match kind.as_str().unwrap_or("") {
"KinsokuPushInFirst" => "",
k => k,
}
.to_string();
para_choice(
lang,
ui,
d,
"kinsokuType",
&kind,
&[
("", "Push In First"),
("KinsokuPushOutFirst", "Push Out First"),
("KinsokuPushOutOnly", "Push Out Only"),
("KinsokuPrioritizeAdjustmentAmount", "Prioritize Adjustment Amount"),
],
);
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(lang, "Hanging Type:"));
let hang = para_value(d, pv, "kinsokuHang").as_str().unwrap_or("none").to_string();
para_choice(lang, ui, d, "kinsokuHang", &hang, &[("none", "None"), ("regular", "Standard"), ("force", "Force")]);
ui.end_row();
});
para_check(ui, d, pv, "bunriKinshi", crate::i18n::tr(lang, "Bunri-Kinshi"));
ui.add_space(4.0);
egui::Grid::new("cjkcomp2").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Mojikumi Set:"));
let raw = para_value(d, pv, "mojikumi");
let current = mojikumi_value(raw.as_str().unwrap_or("")).to_string();
let sets = mojikumi_sets(doc, &current);
let label = |l: &str| if l.is_empty() { crate::i18n::tr(lang, "None").to_string() } else { l.to_string() };
let shown = sets.iter().find(|s| s.0 == current).map_or_else(String::new, |s| label(s.1.as_str()));
egui::ComboBox::from_id_salt("cjkmojikumi").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| {
for (v, l) in &sets {
if ui.selectable_label(*v == current, crate::rtl::widget(ui, label(l.as_str()))).clicked() {
d.fields.insert("p.mojikumi".into(), json!(v));
}
}
});
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(lang, "Leading Model:"));
// Labelled by where the composer measures the leading from (`cjk_line_reference` in
// compose): `akiAbove` from the em box top, `akiBelow` from its bottom.
let mut opts = vec![
(json!("akiAbove"), "Em Box Top/Right"),
(json!("center"), "Em Box Center"),
(json!("roman"), "Roman Baseline"),
(json!("akiBelow"), "Em Box Bottom/Left"),
];
// Kept from IDML; not offered otherwise.
if cf.get(d, "leadingModel") == json!("centerDown") {
opts.push((json!("centerDown"), "Em Box Center (Down)"));
}
char_choice(lang, ui, d, cf, "leadingModel", &opts);
ui.end_row();
});
para_check(ui, d, pv, "rensuuji", crate::i18n::tr(lang, "Rensuuji"));
para_check(ui, d, pv, "treatIdeographicSpaceAsSpace", crate::i18n::tr(lang, "Absorb Ideographic Space at Line End"));
}
/// Grid Settings: whether lines align to the baseline grid, and only the first line.
fn grid_settings(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value) {
use designcraft_doc::GridAlign;
let cur: GridAlign = serde_json::from_value(para_value(d, pv, "gridAlign")).unwrap_or_default();
let set = |d: &mut Dialog, g: GridAlign| {
if let Ok(v) = serde_json::to_value(g) {
d.fields.insert("p.gridAlign".into(), v);
}
};
egui::Grid::new("cjkgrid").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Grid Alignment:"));
let on = cur != GridAlign::None;
let shown = crate::i18n::tr(lang, if on { "Roman Baseline" } else { "None" });
egui::ComboBox::from_id_salt("cjkgridalign").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| {
if ui.selectable_label(!on, crate::rtl::widget(ui, crate::i18n::tr(lang, "None"))).clicked() {
set(d, GridAlign::None);
}
if ui.selectable_label(on, crate::rtl::widget(ui, crate::i18n::tr(lang, "Roman Baseline"))).clicked() && !on {
set(d, GridAlign::AllLines);
}
});
ui.end_row();
});
let mut first = cur == GridAlign::FirstLineOnly;
let check = egui::Checkbox::new(&mut first, crate::rtl::widget(ui, crate::i18n::tr(lang, "Align Only First Line to Grid")));
if ui.add_enabled(cur != GridAlign::None, check).changed() {
set(d, if first { GridAlign::FirstLineOnly } else { GridAlign::AllLines });
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DesignApp;
/// A document with paragraph style Body on its text and character style Emphasis.
fn app_with_text() -> DesignApp {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
app.run("style.paragraph.create", json!({"name": "Body"})).unwrap();
app.run("style.character.create", json!({"name": "Emphasis", "chars": {"fontStyle": "Italic"}})).unwrap();
let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "漢字かな"})).unwrap();
app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 2})).unwrap();
app.run("style.paragraph.apply", json!({"name": "Body"})).unwrap();
app
}
/// The text painted by the open dialog once it has settled.
fn painted(app: &mut DesignApp, ctx: &egui::Context) -> Vec<String> {
fn collect(shape: &egui::Shape, out: &mut Vec<String>) {
match shape {
egui::Shape::Text(t) => out.push(t.galley.job.text.trim().to_string()),
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, out)),
_ => {}
}
}
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0));
let mut texts = Vec::new();
for _ in 0..4 {
let time = ctx.input(|i| i.time) + 1.0 / 60.0;
let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), ..Default::default() }, |ui| {
crate::dialogs::show(app, ui.ctx());
});
output.textures_delta.clear();
texts.clear();
for s in &output.shapes {
collect(&s.shape, &mut texts);
}
}
texts
}
/// The list model, not painted labels: the section list scrolls, so lower rows may be clipped.
#[test]
fn cjk_sections_follow_show_cjk_features() {
let mut app = app_with_text();
let base = [("general", "General"), ("export", "Export Tagging")];
let ids = |app: &DesignApp, para: bool| sections(crate::cjk_features(app), para, &base).into_iter().map(|s| s.0).collect::<Vec<_>>();
app.run("prefs.set", json!({"cjkFeatures": true})).unwrap();
assert_eq!(ids(&app, true), ["general", "cjk.tcy", "cjk.kenten", "cjk.composition", "cjk.grid", "export", "cjk.warichu"]);
assert_eq!(ids(&app, false), ["general", "cjk.tcy", "cjk.kenten", "export", "cjk.warichu"]);
assert_eq!(
sections(true, false, &[("general", "General")]).into_iter().map(|s| s.0).collect::<Vec<_>>(),
["general", "cjk.tcy", "cjk.kenten", "cjk.warichu"]
);
app.run("prefs.set", json!({"cjkFeatures": false})).unwrap();
assert_eq!(ids(&app, true), ["general", "export"]);
assert_eq!(ids(&app, false), ["general", "export"]);
}
#[test]
fn cjk_sections_draw() {
let mut app = app_with_text();
app.run("prefs.set", json!({"cjkFeatures": true})).unwrap();
let ctx = egui::Context::default();
for lang in ["", "ja", "ar"] {
app.run("app.language", json!({"lang": lang})).unwrap();
for s in SECTIONS {
for fields in [
json!({"id": "paragraphStyleOptions", "name": "Body"}),
json!({"id": "paragraphStyleOptions", "new": true}),
json!({"id": "characterStyleOptions", "name": "Emphasis"}),
json!({"id": "characterStyleOptions", "name": "Emphasis", "c.kenten": true, "c.kentenCharacter": "※", "c.warichuLines": null}),
] {
let id = fields["id"].as_str().unwrap().to_string();
let mut f = fields.clone();
f["section"] = json!(s.id);
app.ui.dialog = Some(Dialog::new(&id, f));
painted(&mut app, &ctx);
assert!(app.ui.dialog.is_some(), "{lang} {id} {} stays open", s.id);
}
}
}
}
#[test]
fn kenten_and_japanese_composition_reach_the_style_and_its_paragraph() {
let mut app = app_with_text();
app.run("prefs.set", json!({"cjkFeatures": true})).unwrap();
if let Some(st) = app.session.active_mut() {
let table = designcraft_doc::cjk::MojikumiTable { name: "Body Text".into(), ..Default::default() };
std::sync::Arc::make_mut(&mut st.doc).styles_mut().mojikumi_tables.push(table);
}
let doc = app.session.active().unwrap().doc.clone();
let hard = kinsoku_sets(&doc).into_iter().find(|k| k.name == "HardKinsoku").unwrap();
let moji = mojikumi_sets(&doc, "").into_iter().find(|s| s.1 == "Body Text").unwrap().0;
assert_eq!(moji, "MojikumiTable/Body Text");
let ctx = egui::Context::default();
app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Body", "section": "cjk.kenten"})));
painted(&mut app, &ctx);
{
let d = app.ui.dialog.as_mut().unwrap();
pick_kenten(d, &CharFields { base: &Value::Null, sparse: false }, Some("\u{25CF}"));
d.fields.insert("section".into(), json!("cjk.composition"));
d.fields.insert("p.kinsoku".into(), serde_json::to_value(&hard).unwrap());
d.fields.insert("p.mojikumi".into(), json!(moji));
d.fields.insert("p.kinsokuHang".into(), json!("regular"));
}
painted(&mut app, &ctx);
crate::dialogs::confirm(&mut app).unwrap();
let doc = app.session.active().unwrap().doc.clone();
let st = doc.styles.para("Body").unwrap();
assert_eq!(st.para.kinsoku, Some(Some(hard)));
assert_eq!(st.para.mojikumi.as_deref(), Some("MojikumiTable/Body Text"));
assert_eq!(st.para.kinsoku_hang, Some(designcraft_doc::cjk::KinsokuHang::Regular));
assert_eq!((st.chars.kenten, st.chars.kenten_character.as_deref()), (Some(true), Some("\u{25CF}")));
let a = app.run("type.selectionAttrs", json!({})).unwrap();
assert_eq!(a["para"]["kinsoku"]["name"], "HardKinsoku", "the paragraph follows its style");
assert_eq!(a["para"]["mojikumi"], "MojikumiTable/Body Text");
assert_eq!(a["chars"]["kenten"], true);
assert_eq!(a["chars"]["kentenCharacter"], "\u{25CF}");
// Default Rules (`null`) replaces the set.
app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Body", "section": "cjk.composition", "p.kinsoku": null})));
painted(&mut app, &ctx);
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(app.session.active().unwrap().doc.styles.para("Body").unwrap().para.kinsoku, Some(None));
assert!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["kinsoku"].is_null());
// A character style sets only the kenten attributes.
app.ui.dialog = Some(Dialog::new("characterStyleOptions", json!({"name": "Emphasis", "section": "cjk.kenten"})));
painted(&mut app, &ctx);
{
let d = app.ui.dialog.as_mut().unwrap();
let base = json!({});
pick_kenten(d, &CharFields { base: &base, sparse: true }, Some(""));
}
crate::dialogs::confirm(&mut app).unwrap();
let st = app.session.active().unwrap().doc.styles.char_style("Emphasis").cloned().unwrap();
assert_eq!((st.chars.kenten, st.chars.kenten_character.as_deref()), (Some(true), Some("")));
}
}
+97
View File
@@ -90,6 +90,103 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Ruby…", ["Ruby …", "Ruby…", "Ruby…", "ルビ…", "旁注…"]),
("Kenten", ["Kenten", "Kenten", "Kenten", "圏点", "圈点"]),
("Warichu", ["Warichu", "Warichu", "Warichu", "割り注", "割注"]),
// Style Options: CJK sections.
("Tate-chu-yoko Settings", ["Tate-Chu-Yoko-Einstellungen", "Paramètres Tate-Chu-Yoko", "Ajustes de Tate-Chu-Yoko", "縦中横設定", "纵中横设置"]),
("Kenten Settings", ["Kenten-Einstellungen", "Paramètres Kenten", "Ajustes de Kenten", "圏点設定", "圈点设置"]),
(
"Japanese Composition Settings",
["Japanischer Satz", "Paramètres de composition japonaise", "Ajustes de composición japonesa", "日本語文字組版", "日文排版设置"],
),
("Grid Settings", ["Rastereinstellungen", "Paramètres de grille", "Ajustes de cuadrícula", "グリッド設定", "网格设置"]),
("Warichu Settings", ["Warichu-Einstellungen", "Paramètres Warichu", "Ajustes de Warichu", "割注設定", "割注设置"]),
("Up/Down Position:", ["Position oben/unten:", "Position haut/bas:", "Posición arriba/abajo:", "上下位置:", "上下位置:"]),
("Left/Right Position:", ["Position links/rechts:", "Position gauche/droite:", "Posición izquierda/derecha:", "左右位置:", "左右位置:"]),
("Kenten Type:", ["Kenten-Art:", "Type de kenten:", "Tipo de kenten:", "圏点種類:", "圈点类型:"]),
("Sesame Dot", ["Sesampunkt", "Point sésame", "Punto de sésamo", "ゴマ", "芝麻点"]),
("White Sesame Dot", ["Weißer Sesampunkt", "Point sésame blanc", "Punto de sésamo blanco", "白ゴマ", "空心芝麻点"]),
("Bullseye", ["Zielscheibe", "Cible", "Diana", "蛇の目", "靶心"]),
("Black Circle", ["Schwarzer Kreis", "Cercle noir", "Círculo negro", "黒丸", "实心圆"]),
("Small Black Circle", ["Kleiner schwarzer Kreis", "Petit cercle noir", "Círculo negro pequeño", "小さい黒丸", "小实心圆"]),
("Double Circle", ["Doppelkreis", "Double cercle", "Círculo doble", "二重丸", "双圆"]),
("Black Triangle", ["Schwarzes Dreieck", "Triangle noir", "Triángulo negro", "黒三角", "实心三角"]),
("White Triangle", ["Weißes Dreieck", "Triangle blanc", "Triángulo blanco", "白三角", "空心三角"]),
("White Circle", ["Weißer Kreis", "Cercle blanc", "Círculo blanco", "白丸", "空心圆"]),
("Small White Circle", ["Kleiner weißer Kreis", "Petit cercle blanc", "Círculo blanco pequeño", "小さい白丸", "小空心圆"]),
("Character:", ["Zeichen:", "Caractère:", "Carácter:", "文字:", "字符:"]),
("Kinsoku Set:", ["Kinsoku-Satz:", "Jeu kinsoku:", "Conjunto kinsoku:", "禁則処理セット:", "避头尾集:"]),
("Default Rules", ["Standardregeln", "Règles par défaut", "Reglas predeterminadas", "既定の規則", "默认规则"]),
("Hard Kinsoku", ["Hartes Kinsoku", "Kinsoku strict", "Kinsoku estricto", "強い禁則", "严格避头尾"]),
("Soft Kinsoku", ["Weiches Kinsoku", "Kinsoku souple", "Kinsoku flexible", "弱い禁則", "宽松避头尾"]),
(
"Simplified Chinese Kinsoku",
["Kinsoku für vereinfachtes Chinesisch", "Kinsoku chinois simplifié", "Kinsoku de chino simplificado", "簡体中国語禁則", "简体中文避头尾"],
),
(
"Traditional Chinese Kinsoku",
["Kinsoku für traditionelles Chinesisch", "Kinsoku chinois traditionnel", "Kinsoku de chino tradicional", "繁体中国語禁則", "繁体中文避头尾"],
),
("Korean Kinsoku", ["Koreanisches Kinsoku", "Kinsoku coréen", "Kinsoku coreano", "韓国語禁則", "韩文避头尾"]),
("Kinsoku Type:", ["Kinsoku-Methode:", "Type de kinsoku:", "Tipo de kinsoku:", "禁則調整方式:", "避头尾调整方式:"]),
("Push In First", ["Zuerst einziehen", "Rentrer d'abord", "Primero hacia dentro", "追い込み優先", "先推入"]),
("Push Out First", ["Zuerst hinausschieben", "Repousser d'abord", "Primero hacia fuera", "追い出し優先", "先推出"]),
("Push Out Only", ["Nur hinausschieben", "Repousser uniquement", "Solo hacia fuera", "追い出しのみ", "仅推出"]),
(
"Prioritize Adjustment Amount",
["Anpassungsbetrag bevorzugen", "Priorité à la valeur d'ajustement", "Priorizar la cantidad de ajuste", "調整量を優先", "优先调整量"],
),
("Hanging Type:", ["Hängende Interpunktion:", "Ponctuation suspendue:", "Puntuación colgante:", "ぶら下がり方法:", "悬挂方式:"]),
("Force", ["Erzwingen", "Forcer", "Forzar", "強制", "强制"]),
("Bunri-Kinshi", ["Bunri-Kinshi", "Bunri-Kinshi", "Bunri-Kinshi", "分離禁止処理", "分离禁止"]),
("Mojikumi Set:", ["Mojikumi-Satz:", "Jeu mojikumi:", "Conjunto mojikumi:", "文字組み:", "标点挤压集:"]),
("Leading Model:", ["Zeilenabstandsmodell:", "Modèle d'interlignage:", "Modelo de interlineado:", "行送りの基準位置:", "行距基准位置:"]),
(
"Em Box Top/Right",
["Geviert oben/rechts", "Haut/droite du cadratin", "Parte superior/derecha del cuadratín", "仮想ボディの上/右", "全角字框上/右"],
),
("Em Box Center", ["Geviert Mitte", "Centre du cadratin", "Centro del cuadratín", "仮想ボディの中央", "全角字框居中"]),
(
"Em Box Bottom/Left",
["Geviert unten/links", "Bas/gauche du cadratin", "Parte inferior/izquierda del cuadratín", "仮想ボディの下/左", "全角字框下/左"],
),
(
"Em Box Center (Down)",
["Geviert Mitte (unten)", "Centre du cadratin (bas)", "Centro del cuadratín (abajo)", "仮想ボディの中央(下)", "全角字框居中(下)"],
),
("Roman Baseline", ["Lateinische Grundlinie", "Ligne de base romaine", "Línea de base romana", "欧文ベースライン", "罗马字基线"]),
("Rensuuji", ["Rensuuji", "Rensuuji", "Rensuuji", "連数字処理", "连数字处理"]),
(
"Absorb Ideographic Space at Line End",
[
"Ideografisches Leerzeichen am Zeilenende absorbieren",
"Absorber l'espace idéographique en fin de ligne",
"Absorber el espacio ideográfico al final de línea",
"全角スペースを行末吸収",
"行尾吸收全角空格",
],
),
("Grid Alignment:", ["Am Raster ausrichten:", "Alignement sur la grille:", "Alineación con cuadrícula:", "グリッド揃え:", "网格对齐:"]),
(
"Align Only First Line to Grid",
[
"Nur erste Zeile am Raster ausrichten",
"Aligner uniquement la première ligne sur la grille",
"Alinear solo la primera línea con la cuadrícula",
"1 行目のみグリッドに揃える",
"仅首行对齐网格",
],
),
("Warichu Size:", ["Warichu-Größe:", "Taille warichu:", "Tamaño de warichu:", "割注サイズ:", "割注大小:"]),
("Line Spacing:", ["Zeilenabstand:", "Interligne:", "Interlineado:", "行の間隔:", "行间距:"]),
("Auto", ["Auto", "Auto", "Automático", "自動", "自动"]),
("Line Break Options", ["Umbruchoptionen", "Options de saut de ligne", "Opciones de salto de línea", "改行オプション", "换行选项"]),
(
"Characters Before Break:",
["Zeichen vor dem Umbruch:", "Caractères avant le saut:", "Caracteres antes del salto:", "1 行目の最小文字数:", "换行前最少字符数:"],
),
(
"Characters After Break:",
["Zeichen nach dem Umbruch:", "Caractères après le saut:", "Caracteres después del salto:", "最終行の最小文字数:", "换行后最少字符数:"],
),
("Font", ["Schriftart", "Police", "Fuente", "フォント", "字体"]),
("Size", ["Schriftgrad", "Corps", "Tamaño", "サイズ", "大小"]),
("Character", ["Zeichen", "Caractère", "Carácter", "文字", "字符"]),
+52
View File
@@ -49,6 +49,58 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Tate-Chu-Yoko", "縦中横"),
("Ruby…", "ルビ…"),
("Kenten", "圏点"),
("Tate-chu-yoko Settings", "縦中横設定"),
("Kenten Settings", "圏点設定"),
("Japanese Composition Settings", "日本語文字組版"),
("Grid Settings", "グリッド設定"),
("Warichu Settings", "割注設定"),
("Up/Down Position:", "上下位置:"),
("Left/Right Position:", "左右位置:"),
("Kenten Type:", "圏点種類:"),
("Sesame Dot", "ゴマ"),
("White Sesame Dot", "白ゴマ"),
("Bullseye", "蛇の目"),
("Black Circle", "黒丸"),
("Small Black Circle", "小さい黒丸"),
("Double Circle", "二重丸"),
("Black Triangle", "黒三角"),
("White Triangle", "白三角"),
("White Circle", "白丸"),
("Small White Circle", "小さい白丸"),
("Character:", "文字:"),
("Kinsoku Set:", "禁則処理セット:"),
("Default Rules", "既定の規則"),
("Hard Kinsoku", "強い禁則"),
("Soft Kinsoku", "弱い禁則"),
("Simplified Chinese Kinsoku", "簡体中国語禁則"),
("Traditional Chinese Kinsoku", "繁体中国語禁則"),
("Korean Kinsoku", "韓国語禁則"),
("Kinsoku Type:", "禁則調整方式:"),
("Push In First", "追い込み優先"),
("Push Out First", "追い出し優先"),
("Push Out Only", "追い出しのみ"),
("Prioritize Adjustment Amount", "調整量を優先"),
("Hanging Type:", "ぶら下がり方法:"),
("Force", "強制"),
("Bunri-Kinshi", "分離禁止処理"),
("Mojikumi Set:", "文字組み:"),
("Leading Model:", "行送りの基準位置:"),
("Em Box Top/Right", "仮想ボディの上/右"),
("Em Box Center", "仮想ボディの中央"),
("Em Box Bottom/Left", "仮想ボディの下/左"),
("Em Box Center (Down)", "仮想ボディの中央(下)"),
("Roman Baseline", "欧文ベースライン"),
("Rensuuji", "連数字処理"),
("Absorb Ideographic Space at Line End", "全角スペースを行末吸収"),
("Grid Alignment:", "グリッド揃え:"),
("Align Only First Line to Grid", "1 行目のみグリッドに揃える"),
("Lines:", "行数:"),
("Warichu Size:", "割注サイズ:"),
("Line Spacing:", "行の間隔:"),
("Auto", "自動"),
("Line Break Options", "改行オプション"),
("Characters Before Break:", "1 行目の最小文字数:"),
("Characters After Break:", "最終行の最小文字数:"),
("Font", "フォント"),
("Size", "サイズ"),
("Character", "文字"),