mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 20:00:11 -06:00
Gap 19: ODT ruby read and written
Ruby (`text:ruby`, ODF 1.3 §6.4: a base text with ruby text over it, furigana or pinyin) was read as its base and ruby text run together, and the writer wrote any ruby as plain text (its comment wrongly said ODF has none). Ruby is now read as WordCraft's Phonetic Guide. It is written back as ODF ruby, above its base and centred, with an automatic ruby style. A ruby inside a tracked change is still written as its base text, which the change marks, so the tracking isn't lost. LibreOffice shows the round-tripped ruby over its base, and the content validates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
@@ -30,6 +30,9 @@ pub(super) struct Body<'a> {
|
||||
pub(super) entries: &'a [String],
|
||||
/// The paragraph before had a page break after it: the next one starts a page.
|
||||
break_next: bool,
|
||||
/// A ruby being read: its base text, ruby text, which of them the text goes to (`true`:
|
||||
/// the ruby), and the base's formatting.
|
||||
ruby: Option<(String, String, Option<bool>, Fmt)>,
|
||||
/// The form controls of `office:forms`, placed where a `draw:control` names them.
|
||||
forms: super::forms::FormsReader,
|
||||
pub(super) manifest: Option<&'a HashMap<String, String>>,
|
||||
@@ -128,6 +131,7 @@ impl<'a> Body<'a> {
|
||||
entries: &[],
|
||||
forms: Default::default(),
|
||||
break_next: false,
|
||||
ruby: None,
|
||||
manifest: None,
|
||||
containers: vec![Vec::new()],
|
||||
fmt: Vec::new(),
|
||||
@@ -216,6 +220,38 @@ impl<'a> Body<'a> {
|
||||
Ev::End(q) if self.forms.end(&q) => {}
|
||||
Ev::Start(q, a) if q == "draw:control" => self.place_control(&a),
|
||||
Ev::End(q) if q == "draw:control" => {}
|
||||
// Ruby (ODF 1.3 §6.4): its base and ruby text, read on their own.
|
||||
Ev::Start(q, _) if q == "text:ruby" && self.para.is_some() && self.field.is_none() => {
|
||||
self.ruby = Some((String::new(), String::new(), None, self.cur_fmt()));
|
||||
}
|
||||
Ev::Start(q, _) if self.ruby.is_some() && matches!(q.as_str(), "text:ruby-base" | "text:ruby-text") => {
|
||||
if let Some(r) = &mut self.ruby {
|
||||
r.2 = Some(q == "text:ruby-text");
|
||||
}
|
||||
}
|
||||
Ev::End(q) if self.ruby.is_some() && matches!(q.as_str(), "text:ruby-base" | "text:ruby-text") => {
|
||||
if let Some(r) = &mut self.ruby {
|
||||
r.2 = None;
|
||||
}
|
||||
}
|
||||
Ev::End(q) if q == "text:ruby" && self.ruby.is_some() => {
|
||||
if let Some((base, ruby, _, fmt)) = self.ruby.take() {
|
||||
let (base, ruby) = (base.trim().to_string(), ruby.trim().to_string());
|
||||
match (base.is_empty(), ruby.is_empty()) {
|
||||
(false, false) => self.push_inline(Inline::Ruby { base, ruby, fmt }),
|
||||
// A guide over nothing, or reading nothing: its text.
|
||||
(false, true) | (true, false) => self.push_inline(Inline::Text(if base.is_empty() { ruby } else { base }, fmt)),
|
||||
(true, true) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ev::Text(t) if self.ruby.is_some() => {
|
||||
if let Some((base, ruby, Some(in_ruby), _)) = &mut self.ruby {
|
||||
let s = if *in_ruby { ruby } else { base };
|
||||
let room = MAX_FIELD_RESULT.saturating_sub(s.len());
|
||||
s.extend(t.chars().take(room));
|
||||
}
|
||||
}
|
||||
Ev::Start(q, a) => {
|
||||
if self.depth < MAX_XML_DEPTH {
|
||||
self.start(&q, &a);
|
||||
|
||||
@@ -848,8 +848,8 @@ fn tracked_changes_round_trip_through_export() {
|
||||
|
||||
#[test]
|
||||
fn an_inserted_ruby_keeps_its_tracked_insertion() {
|
||||
// ODF has no ruby: its base text is written as text, and the insertion on the ruby must
|
||||
// still be a change range around that text, not plain untracked text.
|
||||
// A ruby inside a tracked insertion is written as its base text, so that it stays inside
|
||||
// the change range rather than becoming plain untracked text.
|
||||
use wordcraft_doc::RevisionKind::Insert;
|
||||
let props = wordcraft_doc::props::CharProps::default();
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("before ", props.clone());
|
||||
|
||||
@@ -29,6 +29,7 @@ mod para_borders;
|
||||
mod polygons;
|
||||
mod rotation;
|
||||
mod rtl;
|
||||
mod ruby;
|
||||
mod settings;
|
||||
mod shapes;
|
||||
mod spacing;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Ruby (ODF 1.3 §6.4 `text:ruby`: a base text with ruby text over it, furigana or pinyin) read as
|
||||
//! WordCraft's Phonetic Guide and written back as ODF ruby.
|
||||
|
||||
use crate::odt::tests::{NS_T, content_of, package};
|
||||
use crate::odt::{export, import};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
|
||||
fn open(p: &str) -> wordcraft_doc::Document {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="Ru1" style:family="ruby"><style:ruby-properties style:ruby-position="above" style:ruby-align="center"/></style:style></office:automatic-styles><office:body><office:text>{p}</office:text></office:body></office:document-content>"#
|
||||
);
|
||||
import(&package(&content, None)).unwrap()
|
||||
}
|
||||
|
||||
/// Each ruby: (base, ruby text), and the paragraph's text.
|
||||
fn rubies(d: &wordcraft_doc::Document) -> (Vec<(String, String)>, String) {
|
||||
let p = d.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
let r = p
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| if let InlineObject::Ruby { base, ruby, .. } = o { Some((base.clone(), ruby.clone())) } else { None })
|
||||
.collect();
|
||||
(r, p.text.replace(wordcraft_doc::para::OBJ, "[R]"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_is_read_as_a_phonetic_guide_and_written_back() {
|
||||
let d = open(
|
||||
r#"<text:p>Ruby: <text:ruby text:style-name="Ru1"><text:ruby-base>漢字</text:ruby-base><text:ruby-text>かんじ</text:ruby-text></text:ruby> after.</text:p>"#,
|
||||
);
|
||||
assert_eq!(rubies(&d), (vec![("漢字".to_string(), "かんじ".to_string())], "Ruby: [R] after.".to_string()));
|
||||
let c = content_of(&export(&d).unwrap());
|
||||
assert!(c.contains("<text:ruby-base>漢字</text:ruby-base><text:ruby-text>かんじ</text:ruby-text></text:ruby>"), "{c}");
|
||||
assert!(c.contains(r#"style:family="ruby"><style:ruby-properties style:ruby-position="above""#), "{c}");
|
||||
assert_eq!(rubies(&import(&export(&d).unwrap()).unwrap()).0, vec![("漢字".to_string(), "かんじ".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_without_a_reading_or_a_base_is_its_text() {
|
||||
let d = open(r#"<text:p><text:ruby><text:ruby-base>漢字</text:ruby-base><text:ruby-text/></text:ruby>!</text:p>"#);
|
||||
assert_eq!(rubies(&d), (Vec::new(), "漢字!".to_string()));
|
||||
let d = open(
|
||||
r#"<text:p><text:ruby><text:ruby-base><text:span>基</text:span>本</text:ruby-base><text:ruby-text>きほん</text:ruby-text></text:ruby></text:p>"#,
|
||||
);
|
||||
assert_eq!(rubies(&d).0, vec![("基本".to_string(), "きほん".to_string())]);
|
||||
}
|
||||
@@ -159,6 +159,8 @@ pub(super) struct Writer {
|
||||
/// kept objects' folders with their media types, for the manifest.
|
||||
pub(super) object_names: usize,
|
||||
pub(super) object_dirs: Vec<(String, String)>,
|
||||
/// Ruby was written: its automatic ruby style is.
|
||||
ruby_used: bool,
|
||||
/// Form controls written (`form:*` elements for `office:forms`), and whether this writer's
|
||||
/// text may hold them (the body's; not headers and footers).
|
||||
pub(super) forms: Vec<String>,
|
||||
@@ -294,8 +296,6 @@ impl Writer {
|
||||
|
||||
fn plain_inlines(&mut self, inl: &[Inline], out: &mut String) {
|
||||
let mut prev_space = true;
|
||||
// ODF has no ruby: its base text goes through the text path below.
|
||||
let inl = model::ruby_as_text(inl);
|
||||
let last = inl.len().saturating_sub(1);
|
||||
for (idx, i) in inl.iter().enumerate() {
|
||||
match i {
|
||||
@@ -420,8 +420,25 @@ impl Writer {
|
||||
prev_space = result.ends_with(' ');
|
||||
}
|
||||
},
|
||||
// Read as its base text by `ruby_as_text` above.
|
||||
Inline::Ruby { .. } => {}
|
||||
// Inside a tracked change: its base text, which the text path marks as changed.
|
||||
Inline::Ruby { base, fmt, .. } if fmt.ins.is_some() || fmt.del.is_some() => {
|
||||
self.plain_inlines(&[Inline::Text(base.clone(), fmt.clone())], out);
|
||||
prev_space = false;
|
||||
}
|
||||
// Ruby (§6.4): the base text in its formatting, the ruby text over it.
|
||||
Inline::Ruby { base, ruby, fmt } => {
|
||||
self.ruby_used = true;
|
||||
let base_xml = match self.text_style(&Fmt { link: None, ..fmt.clone() }) {
|
||||
Some(n) => format!("<text:span text:style-name=\"{n}\">{}</text:span>", x(base)),
|
||||
None => x(base),
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"<text:ruby text:style-name=\"{}Ru1\"><text:ruby-base>{base_xml}</text:ruby-base><text:ruby-text>{}</text:ruby-text></text:ruby>",
|
||||
self.prefix,
|
||||
x(ruby)
|
||||
));
|
||||
prev_space = false;
|
||||
}
|
||||
Inline::Equation { linear, display } => {
|
||||
self.formula(linear, *display, out);
|
||||
prev_space = false;
|
||||
@@ -748,6 +765,12 @@ impl Writer {
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.ruby_used {
|
||||
s.push_str(&format!(
|
||||
"<style:style style:name=\"{}Ru1\" style:family=\"ruby\"><style:ruby-properties style:ruby-position=\"above\" style:ruby-align=\"center\"/></style:style>",
|
||||
self.prefix
|
||||
));
|
||||
}
|
||||
for (i, props) in self.row_styles.iter().enumerate() {
|
||||
s.push_str(&format!(
|
||||
"<style:style style:name=\"{}Row{}\" style:family=\"table-row\"><style:table-row-properties{props}/></style:style>",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user