From 2ca53ef585226c50f4c90d0356bd56491908b1ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 18 Oct 2026 21:39:17 -0400 Subject: [PATCH] R8.2: Word EQ fields open as formulas; inline objects follow their run Gitea #9. Patch 0049 moves inline pictures and OLE objects with their run's w:position. Patches 0050 and 0051 turn EQ fields into Math objects; unreadable fields show their code. Round 3 findings (lowercase \s\do tail, split subscripts, \ai/\di, italics through OMML) fixed and covered by word.rs; no fourth review. Co-Authored-By: Claude --- agents.md | 1 + crates/ely-lok/src/client/tests.rs | 1 + crates/ely-lok/src/client/tests/word.rs | 121 +++++ ...r-inline-objects-move-with-their-run.patch | 199 ++++++++ .../0050-writer-eq-field-parser.patch | 454 ++++++++++++++++++ .../0051-writer-eq-fields-as-formulas.patch | 169 +++++++ tasks/TASKS.md | 2 +- tasks/reviews/R8.2-r1.md | 11 + tasks/reviews/R8.2-r2.md | 9 + tasks/reviews/R8.2-r3.md | 9 + tests/fixtures/office/word.docx | Bin 0 -> 8349 bytes 11 files changed, 975 insertions(+), 1 deletion(-) create mode 100644 crates/ely-lok/src/client/tests/word.rs create mode 100644 patches/libreoffice/0049-writer-inline-objects-move-with-their-run.patch create mode 100644 patches/libreoffice/0050-writer-eq-field-parser.patch create mode 100644 patches/libreoffice/0051-writer-eq-fields-as-formulas.patch create mode 100644 tasks/reviews/R8.2-r1.md create mode 100644 tasks/reviews/R8.2-r2.md create mode 100644 tasks/reviews/R8.2-r3.md create mode 100644 tests/fixtures/office/word.docx diff --git a/agents.md b/agents.md index 3277699..d6eeaf8 100644 --- a/agents.md +++ b/agents.md @@ -318,3 +318,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-16(Gitea #3):Windows 与 Linux 打印(R7.6)。`print_pdf` 改为异步:对话框在应用线程上,读页与出作业在自己的线程上(`print::off`),应用不卡。Windows:系统打印对话框(`PrintDlgEx`,Windows 11 显示为新式对话框),打开前把默认打印机的设置改成文档的纸张与方向(按打印机自己的纸型匹配(纸型须装得下页面,至多大 2 毫米,Linux 的 PWG 名同此);没有即自定义尺寸),设置须连同打印机名(`DEVNAMES`)一起交给对话框,否则对话框改用打印机自己的偏好(实测:Letter 文档印成 A4);所选页码范围照办,份数交驱动;勾“打印到文件”时文档输出为 `FILE:`,由打印子系统问文件名。驱动不支持所设纸张时会换成别的(实测“Microsoft Print to PDF”没有自定义尺寸,1000×600 点的页面换成 A4),所以出作业之前(`StartDoc` 之前)按打印机实际的纸张核对每一页:超出纸张半点以上(纸张尺寸至多舍入到整点,`print::ROUNDING`)、转过来也超出即报错,什么也不印(实测:600×842 点的页面原在 A4 上印出、右缘被裁)。页面由 Windows 自带的 PDF 渲染(`Windows.Data.Pdf`)按打印机分辨率分条画出,按原大从纸张的角上放(扣去不可打印的偏移),只在转过来才放得下时左转 90 度;渲染器的尺寸按 DIP 算、乘显示缩放,每次作业先试画一次量出倍数。`ELY_PRINT_TO` 时不弹对话框,经“Microsoft Print to PDF”写成文件(测试用)。`GlobalFree` 成功时返回空,windows-rs 读成错误码 0 的错误,须当作成功(否则打印报“操作成功完成”)。Linux:经 xdg 桌面门户的 `org.freedesktop.portal.Print`(`PreparePrint` 后带其令牌 `Print`,交出 PDF 的文件描述符),页面设置为文档纸张(标准纸型给 PWG 名,否则自定义毫米尺寸),边距为零,只许输出 PDF(`supported_output_file_formats`,否则对话框可选 PostScript,写出的却是 PDF);门户照原样交出所给的字节,对话框的选择由应用排定(`print/linux/sheets.rs`):GTK 设置里的页码范围(从零起)、奇偶页、倒序、份数与逐份、缩放、每张页数与排列,排到对话框所选纸张与方向上,每页为一个表单对象(带可打印批注的外观;带 NoRotate 的批注在转过的页面上绕其左上角转回、保持正立,同 MuPDF;外观的资源名避开页面已有的;可打印却没有外观的批注即报错,链接与弹出说明本无外观),单页按原大乘缩放从纸张左上角放,超出半点以上而转过来放得下即左转,否则报错;方向照 GTK 的写法(`reverse-portrait`,门户文档的页面设置同此);奇偶页只在 GTK 的文件与 lpr 后端时由 Ely 排(设置里有 `output-uri` 或 `lpr-commandline`),CUPS 后端自己把它交给 CUPS(`cups-page-set`,GTK 3.24.52 实测),Ely 再排一次即丢页;份数、每张页数等 GTK 留给应用,Ely 照排;裁剪框超出媒体框的部分不印(取两者的交);页码范围的开放终点 GTK 写作 -1(`1--1`),倒着的范围即报错;多页按格居中,再交内存文件(`memfd`);直接用 zbus:ashpd 0.13.13 的 `PageSetup`、`Settings` 缺任一字段即解析失败,真门户的回复常不带 `PPDName`。页面尺寸由 lopdf 读(CropBox,否则 MediaBox,继承、`/Rotate` 与页面自己的 `/UserUnit`)。打印不再按平台禁用(原 `print::BUILT` 与“此平台尚未构建”删去)。替身门户的测试核对选页、缩放、倒序与份数后交出的页面与位置,与只许 PDF。ElyPDF 的打印副本(`ely_print_copy`)按打印的规则画:可打印(不隐藏)的批注与表单控件,外观画进页面内容(位置照 MuPDF 自己的 `pdf_annot_transform`,与屏上一致;无外观的先由 MuPDF 生成,MuPDF 原只在显示时放在本地表里、不存入文件),其余批注与表单去掉,只留链接;系统的渲染器按屏幕规则画(不打印的照画,“打印但不显示”的不画,实测 Windows),画进内容后各渲染器都只见要打印的;仍画不出的即报错;`tests/output.rs` 渲染副本核对。真窗口(Windows):ElyDoc 的 Ctrl+P、Ely 的打印对话框、系统对话框、“Microsoft Print to PDF”的另存为,印出的 PDF 为文档的 Letter;系统对话框取消时不打印、不报错(`.work/r76/print.ps1`)。未验证:Linux 真桌面上的门户(只对替身门户核对);实体打印机。 - 2026-10-16(用户定):Windows 资源管理器的右键“新建”可建 DOCX、XLSX、PPTX 与 Markdown 文件(R7.9)。空白文件在 `packaging/windows/new`(`templates.sh` 以本构建的内核把空文件转存而得,无作者与日期;Markdown 为空文件),随所属的应用装在安装目录(列在 `.ely-installed` 里),`HKCU\…\.<扩展名>\ShellNew` 的 `FileName` 指向它。资源管理器只在扩展名有默认 ProgID 时列出新建项(实测:只有 `ShellNew`、或只在 `OpenWithProgids` 下时都不列);所以只在该类型没有任何程序处理时(HKCR 下无默认值,或默认值所指的类不存在:本机 Windows 11 的 `.md` 指向不存在的 `Markdown Document`)把默认值设为本应用的 ProgID(写在 HKCU,用户自己的 `UserChoice` 不动),已有的从不改动(装着 Office、WPS 时新建菜单用它们自己的项);没有处理程序的这四种类型从此由 Ely 打开。卸载只删仍是本安装写下的 `ShellNew` 与默认值。ProgID 的名称为“<应用> Document”(新建菜单与类型列显示它)。`tests/scripts/types.sh` 核对四种都有新建项,默认值只在判空之后写。 - 2026-10-16(用户定):ElyMark 打开的块保持原样(R7.5)。组件库 bff7ebc 起,可视模式里按下的块以原文显示在它合上时的框里(同样的边距与透明边框),字号、行高与颜色同合上时:段落、列表、引用等为正文,标题按其级数的字号、1.3 倍行高、加粗(六级为次要色),照原文写出的块(链接定义等)为等宽次要色;不再加深色底板与另一套边距,按下前后不挪位置。代码块、块公式与 front matter 仍有自己的底板(代码与 front matter 为等宽小号,front matter 为次要色,同合上时)。输入框的着色(源码模式同用)给标题与粗体同一字重(原为半粗,压过所设的加粗),六级标题里的字照为次要色(行内代码照为正文色,2e65b05),front matter 照 front matter 读(原读成分隔线加标题,键名变亮变粗)(1ff993d、962964e)。表格单元格照旧。打字机模式的居中另按两端余量判定(原只按块高与视图高,打开前后同高时首块不再居中)。同批带入组件库 cdc5ce8(标题栏拖动区里的控件按下归控件,Windows)。Windows 真窗口核对(`.work/r75`):段落、三级标题、代码块打开前后位置相同。 +- 2026-10-18:Word 公式(R8.2,Gitea #9)。附件 math.docx 的六类公式逐类核对(本机无 Word,以文件所写为准):键盘输入、OMML(内核转为公式对象)、MathType(OLE,显示随带的 WMF 预览)、上下标、图片原本就对;差别在两处根因。其一,运行的 `w:position`(升降)原先只给文字,图片与 OLE 公式所在的运行失去它(25 张图片与一个 ½ 浮在行上):补丁 `0049-writer-inline-objects-move-with-their-run.patch` 把它作为固定升降写到对象的锚字符上(按锚字符自己的字号换算百分比,不扩展到其后的文字),版面让“作为字符”的对象随锚字符的固定升降移动(vertAlign 的自动上下标不动对象,同原样);各导出本就写出锚字符的升降,DOCX 往返仍为 `w:position`。其二,EQ 域原先只认 `\o`、`\s\do` 与注音,其余(如分数 `\f`)整个消失:补丁 `0050-writer-eq-field-parser.patch`(读法)与 `0051-writer-eq-fields-as-formulas.patch`(导入)把 EQ 域转为公式对象:`\f` 分数、`\r` 根式、`\s` 上下标、`\b` 括号、`\l` 列表、`\a` 阵列、`\i` 积分与求和求积;`\x` 与 `\o` 只留内容(无框、无叠印),`\d` 只前移,`\ai`、`\di` 只是行距(不升降),开关不分大小写;每个命令字符保留其运行的上下标(Word 照此画),同一段上下标里字样变了仍是一个上下标;斜体字母写成标识符(StarMath 与 OMML 都画成斜体,DOCX 往返不丢),斜体的数字与符号写成 `ital` 文字,存为 DOCX 后成正体(OMML 无此写法,已知限制);没有底的上下标以空文字为底(空的底在 OMML 里是占位符,重开成 ``);公式取域的字号;读不通的命令以其域代码显示为文字,不消失。只有整个域是 `\s\do N(文字)` 时照原样成为下移的文字,其余都经读法(原先小写的 `\s\do` 读到第一个括号即止,其后丢失)。`client/tests/word.rs` 以 `tests/fixtures/office/word.docx`(由 `.work/r8/gen/eq.py` 生成,不入库)核对公式文本(含开头的空格、大写开关、读不通的域、字样变化的下标、`\ai`、`\di`、小写 `\s\do` 的尾部与嵌套)、DOCX 往返后的斜体、下标与无占位符、锚字符的升降与图块里图片低于文字(去掉版面的升降即失败)。审查三轮:第 3 轮的四处已修并以上述测试核对,未再审查(每条至多三轮)。仍与 Word 不同:编辑区里公式与图片四周有内核的辅助框线(`ShowBoundaries`,视图辅助,不印出);DOCX 另存后重开行距变宽,因内核导出改写文档默认字体与段距,与公式无关。 diff --git a/crates/ely-lok/src/client/tests.rs b/crates/ely-lok/src/client/tests.rs index 6ae8034..03beb1e 100644 --- a/crates/ely-lok/src/client/tests.rs +++ b/crates/ely-lok/src/client/tests.rs @@ -29,6 +29,7 @@ mod sheet; pub(crate) mod show; mod slides; mod tools; +mod word; mod workbook; /// The pinned LibreOffice commit the build must report. diff --git a/crates/ely-lok/src/client/tests/word.rs b/crates/ely-lok/src/client/tests/word.rs new file mode 100644 index 0000000..898cc61 --- /dev/null +++ b/crates/ely-lok/src/client/tests/word.rs @@ -0,0 +1,121 @@ +//! Gitea #9: Word's EQ fields and lowered inline objects. + +use super::input::saved; +use super::*; + +/// `tests/fixtures/office/word.docx` open in a fresh worker. +fn word() -> (tempfile::TempDir, Worker, Doc) { + let dir = tempfile::tempdir().expect("a temp dir"); + let install = install().expect("a pinned LOK build"); + let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker"); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/office/word.docx"); + let doc = worker.load(&crate::file_url(&fixture)).expect("the document loads"); + (dir, worker, doc) +} + +/// The text position of the span around the picture's frame. +fn picture_position(fodt: &str) -> String { + let named = fodt.find("draw:name=\"red\"").expect("the picture"); + let frame = fodt[..named].rfind("").expect("the style ends")]; + let position = props.find("style:text-position=\"").expect("the span is raised or lowered"); + props[position + 21..].split('"').next().expect("a position").to_owned() +} + +#[test] +fn eq_fields_open_as_formulas() { + if install().is_none() { + eprintln!("SKIP: no pinned LOK build"); + return; + } + let (dir, mut worker, doc) = word(); + let fodt = saved(&dir, &mut worker, &doc, "word.fodt"); + assert!( + fodt.contains("{{a}_{"10"}} over {{a}_{"3"}}"), + "the fraction keeps its italics and subscripts" + ); + assert!(fodt.contains("nroot{"3"}{"x"}"), "the radical"); + assert!(fodt.contains("sum from{"1"} to{"5"}"), "an uppercase \\SU is a sum"); + assert!( + fodt.contains("matrix{{"a"} # {"b"} ## {"c"} # {"d"}}"), + "an uppercase \\CO2 makes two columns" + ); + assert!(fodt.contains("eq \\f(a)"), "an unreadable field shows its code"); + assert!(fodt.contains("eq \\b\\lc\\★(x)"), "an unknown bracket shows its code"); + let short = formulas(&fodt).into_iter().find(|math| math.contains("# {}}")).expect("the short array"); + assert!(short.contains(">c<"), "a short last row keeps its operand"); + assert_eq!(fodt.matches('∑').count(), 2, "\\SU and \\fc\\∑ both draw a sum"); + assert!(fodt.contains('⌊') && fodt.contains('⌋'), "floor brackets draw"); + assert!(!fodt.contains("MERGEFORMAT"), "a general switch is no formula text"); + assert!(fodt.contains("{"m"} over {"n"}<"), "nothing after the fraction"); + assert_eq!(subscripts(&fodt, ">p<"), 1, "a style change keeps one subscript"); + assert!(fodt.contains(""u" stack{{"v"}}"), "\\ai only spaces lines"); + assert!(fodt.contains(""w" stack{{"k"}}"), "\\di only spaces lines"); + assert!(fodt.contains("_{{"g"}} "+h""), "a lowercase \\s\\do keeps its tail"); + assert!(fodt.contains("_{{{"r"} over {"s"}}}"), "and its nested fraction"); + assert!(fodt.contains(">lowered"), "a plain \\s\\do stays lowered text"); +} + +#[test] +fn eq_formulas_survive_docx() { + if install().is_none() { + eprintln!("SKIP: no pinned LOK build"); + return; + } + let (dir, mut worker, doc) = word(); + let docx = dir.path().join("again.docx"); + doc.save_as(&mut worker, &crate::file_url(&docx), "docx").expect("the DOCX saves"); + let back = worker.load(&crate::file_url(&docx)).expect("the DOCX reopens"); + let fodt = saved(&dir, &mut worker, &back, "again.fodt"); + assert!( + fodt.contains("{{a} rsub {"10"}} over {{a} rsub {"3"}}"), + "the italic letters stay italic" + ); + assert!(!fodt.contains("mathvariant=\"normal\">a<"), "no upright a"); + assert_eq!(subscripts(&fodt, ">p<"), 1, "one subscript still"); + assert!(fodt.contains("{""} rsub {"g"} "+h""), "the tail survives"); + assert!(!fodt.contains("<?>"), "no placeholder for a missing base"); +} + +/// Subscripts in the formula holding `mark`. +fn subscripts(fodt: &str, mark: &str) -> usize { + formulas(fodt).into_iter().find(|math| math.contains(mark)).expect("the formula").matches("").count() +} + +/// Each embedded formula's MathML. +fn formulas(fodt: &str) -> Vec<&str> { + fodt.split("").expect("closed")]).collect() +} + +#[test] +fn a_lowered_picture_moves_with_its_run_and_keeps_it() { + if install().is_none() { + eprintln!("SKIP: no pinned LOK build"); + return; + } + let (dir, mut worker, doc) = word(); + let fodt = saved(&dir, &mut worker, &doc, "word.fodt"); + // 11 points down, against the 11 point anchor. + assert_eq!(picture_position(&fodt), "-100% 100%"); + let docx = dir.path().join("again.docx"); + doc.save_as(&mut worker, &crate::file_url(&docx), "docx").expect("the DOCX saves"); + let back = worker.load(&crate::file_url(&docx)).expect("the DOCX reopens"); + let again = saved(&dir, &mut worker, &back, "again.fodt"); + assert_eq!(picture_position(&again), "-100% 100%", "the position survives DOCX"); + // The square's red sits below the line's text. + let tile = back.paint_tile(&mut worker, (1440, 1440, 3840, 1920), (256, 128)).expect("a tile"); + let rows = |hit: fn(&[u8]) -> bool| { + let mut lit: Vec = tile.pixels.chunks_exact(4).enumerate().filter(|(_, px)| hit(px)).map(|(at, _)| at / 256).collect(); + lit.dedup(); + let first = *lit.first().expect("some pixels"); + let line = lit.iter().zip(first..).take_while(|(row, want)| **row <= *want).last().expect("a row"); + (first, *line.0) + }; + let red = rows(|px| px[2] > 200 && px[1] < 60 && px[0] < 60); + let ink = rows(|px| px[0] < 80 && px[1] < 80 && px[2] < 80); + assert!(red.1 > ink.1 + 4, "the picture hangs below the text: red {red:?}, text {ink:?}"); +} diff --git a/patches/libreoffice/0049-writer-inline-objects-move-with-their-run.patch b/patches/libreoffice/0049-writer-inline-objects-move-with-their-run.patch new file mode 100644 index 0000000..20752e2 --- /dev/null +++ b/patches/libreoffice/0049-writer-inline-objects-move-with-their-run.patch @@ -0,0 +1,199 @@ +Writer: an inline object moves with its run's w:position + +Word lowers or raises an inline picture or OLE object with the +w:position of its run; MathType equations rely on it to sit on +the baseline (Gitea #9: 25 pictures and an OLE fraction stood +above their lines). The import kept w:position as a deferred +character property and applied it to text only, so an object's +run lost it. + +The import now puts the position on the object's anchor +character, as a fixed escapement against that character's own +font size, and does not expand it to the text after. Layout +moves an as-character object by its anchor character's fixed +escapement (vertAlign's automatic values keep objects still, +as before). Every export already writes the anchor character's +escapement (w:position in DOCX), so the position round-trips. +The w:position arithmetic moves into one helper both use. + +--- a/sw/source/core/inc/ascharanchoredobjectposition.hxx ++++ b/sw/source/core/inc/ascharanchoredobjectposition.hxx +@@ -86,6 +86,9 @@ namespace objectpositioning + // method to cast + const SwTextFrame& GetAnchorTextFrame() const; + ++ // the anchor character's fixed raise ++ SwTwips GetRaise() const; ++ + /** determine the relative position to base line for object position + + @param _ObjBoundHeight +--- a/sw/source/core/objectpositioning/ascharanchoredobjectposition.cxx ++++ b/sw/source/core/objectpositioning/ascharanchoredobjectposition.cxx +@@ -29,6 +29,11 @@ + #include + #include + #include ++#include ++#include ++#include ++#include ++#include + + + using namespace ::com::sun::star; +@@ -60,6 +65,23 @@ SwAsCharAnchoredObjectPosition::SwAsCharAnchoredObjectPosition( + SwAsCharAnchoredObjectPosition::~SwAsCharAnchoredObjectPosition() + {} + ++/** the anchor character's fixed raise */ ++SwTwips SwAsCharAnchoredObjectPosition::GetRaise() const ++{ ++ const SwPosition* pAnchor = GetFrameFormat().GetAnchor().GetContentAnchor(); ++ const SwTextNode* pNode = pAnchor ? pAnchor->GetNode().GetTextNode() : nullptr; ++ if (!pNode) ++ return 0; ++ SfxItemSetFixed aSet( ++ const_cast(pNode->GetDoc().GetAttrPool())); ++ const sal_Int32 nIndex = pAnchor->GetContentIndex(); ++ pNode->GetParaAttr(aSet, nIndex, nIndex + 1); ++ const short nEsc = aSet.Get(RES_CHRATR_ESCAPEMENT).GetEsc(); ++ if (nEsc == DFLT_ESC_AUTO_SUPER || nEsc == DFLT_ESC_AUTO_SUB) ++ return 0; ++ return SwTwips(aSet.Get(RES_CHRATR_FONTSIZE).GetHeight()) * nEsc / 100; ++} ++ + /** method to cast to needed type */ + const SwTextFrame& SwAsCharAnchoredObjectPosition::GetAnchorTextFrame() const + { +@@ -162,7 +184,8 @@ void SwAsCharAnchoredObjectPosition::CalcPosition() + const SwTwips nObjBoundHeight = ( mnFlags & AsCharFlags::Rotate ) + ? aObjBoundRect.Width() + : aObjBoundRect.Height(); +- const SwTwips nRelPos = GetRelPosToBase( nObjBoundHeight, rVert ); ++ // it moves with its raised character, as in Word ++ const SwTwips nRelPos = GetRelPosToBase( nObjBoundHeight, rVert ) - GetRaise(); + + // for initial positioning: + // adjust the proposed anchor position by difference between +--- a/sw/source/writerfilter/dmapper/DomainMapper.cxx ++++ b/sw/source/writerfilter/dmapper/DomainMapper.cxx +@@ -3979,26 +3979,11 @@ void DomainMapper::processDeferredCharacterProperties( + { + case NS_ooxml::LN_EG_RPrBase_position: + { +- double nEscapement = 0; +- sal_Int8 nProp = 0; +- if ( nIntValue ) +- { +- nProp = 100; +- double fFontSize = 0; +- m_pImpl->GetAnyProperty(PROP_CHAR_HEIGHT, rContext) >>= fFontSize; +- if ( fFontSize ) +- // nIntValue is in half-points, fontsize is in points, escapement is a percentage. +- nEscapement = round( nIntValue/2.0 / fFontSize * 100 ); +- else +- nEscapement = nIntValue > 0 ? DFLT_ESC_SUPER : DFLT_ESC_SUB; +- } +- if ( nEscapement > MAX_ESC_POS ) +- nEscapement = MAX_ESC_POS; +- else if ( nEscapement < -MAX_ESC_POS ) +- nEscapement = -MAX_ESC_POS; +- +- rContext->Insert(PROP_CHAR_ESCAPEMENT, uno::Any( sal_Int16(nEscapement) ) ); +- rContext->Insert(PROP_CHAR_ESCAPEMENT_HEIGHT, uno::Any( nProp ) ); ++ double fFontSize = 0; ++ m_pImpl->GetAnyProperty(PROP_CHAR_HEIGHT, rContext) >>= fFontSize; ++ const sal_Int16 nEscapement = DomainMapper_Impl::GetPositionEscapement(nIntValue, fFontSize); ++ rContext->Insert(PROP_CHAR_ESCAPEMENT, uno::Any( nEscapement ) ); ++ rContext->Insert(PROP_CHAR_ESCAPEMENT_HEIGHT, uno::Any( sal_Int8(nIntValue ? 100 : 0) ) ); + } + break; + default: +--- a/sw/source/writerfilter/dmapper/DomainMapper_Impl.hxx ++++ b/sw/source/writerfilter/dmapper/DomainMapper_Impl.hxx +@@ -345,6 +345,7 @@ public: + const css::uno::Reference& GetStartRange() const { return m_xStartRange; } + + void AppendCommand(std::u16string_view rPart); ++ /// Deleted command text has no look. + const OUString& GetCommand() const {return m_sCommand[m_bCommandType]; } + bool GetCommandIsEmpty(bool bType) const { return m_sCommand[bType].isEmpty(); } + void SetCommandType(bool cType) { m_bCommandType = cType; } +@@ -839,6 +840,10 @@ public: + void MergeAtContentImageRedlineWithNext(const css::uno::Reference& xTextAppend); + void appendTextPortion( const OUString& rString, const PropertyMapPtr& pPropertyMap ); + void appendTextContent(const css::uno::Reference&, const css::uno::Sequence&); ++ /// w:position's half-points as an escapement percentage. ++ static sal_Int16 GetPositionEscapement(sal_Int32 nHalfPoints, double fFontSize); ++ /// Word moves an inline object with its run's w:position. ++ void moveWithRun(const css::uno::Reference& xContent); + void appendOLE( const OUString& rStreamName, const std::shared_ptr& pOleHandler ); + void appendStarMath( const Value& v); + void adjustLastPara(sal_Int8 nAlign); +--- a/sw/source/writerfilter/dmapper/DomainMapper_Impl.cxx ++++ b/sw/source/writerfilter/dmapper/DomainMapper_Impl.cxx +@@ -135,6 +135,13 @@ + #include + #include + #include ++#include ++#include ++#include ++#include ++#include ++#include ++#include + #include + #include + #include +@@ -3652,6 +3659,7 @@ void DomainMapper_Impl::appendTextContent( + xTextAppendAndConvert->insertTextContentWithProperties( xContent, xPropertyValues, m_aTextAppendStack.top().xInsertPosition ); + else + xTextAppendAndConvert->appendTextContent( xContent, xPropertyValues ); ++ moveWithRun(xContent); + } + catch(const lang::IllegalArgumentException&) + { +@@ -3661,6 +3669,41 @@ void DomainMapper_Impl::appendTextContent( + } + } + ++sal_Int16 DomainMapper_Impl::GetPositionEscapement(sal_Int32 nHalfPoints, double fFontSize) ++{ ++ if (!nHalfPoints) ++ return 0; ++ // half-points against the font size in points, as a percentage ++ double fEscapement = fFontSize ? round(nHalfPoints / 2.0 / fFontSize * 100) ++ : nHalfPoints > 0 ? DFLT_ESC_SUPER : DFLT_ESC_SUB; ++ return sal_Int16(std::clamp(fEscapement, -MAX_ESC_POS, MAX_ESC_POS)); ++} ++ ++void DomainMapper_Impl::moveWithRun(const uno::Reference& xContent) ++{ ++ auto& rDeferred = m_StreamStateStack.top().deferredCharacterProperties; ++ auto it = rDeferred.find(NS_ooxml::LN_EG_RPrBase_position); ++ if (it == rDeferred.end()) ++ return; ++ SwXFrame* pFrame = dynamic_cast(xContent.get()); ++ SwFrameFormat* pFormat = pFrame ? pFrame->GetFrameFormat() : nullptr; ++ if (!pFormat || pFormat->GetAnchor().GetAnchorId() != RndStdIds::FLY_AS_CHAR) ++ return; ++ // the anchor character carries the raise ++ SwPaM aChar(*pFormat->GetAnchor().GetContentAnchor()); ++ aChar.SetMark(); ++ aChar.GetPoint()->AdjustContent(1); ++ // a percentage of the anchor character's own size ++ SfxItemSetFixed aSize(aChar.GetDoc().GetAttrPool()); ++ aChar.GetPointNode().GetTextNode()->GetParaAttr(aSize, aChar.Start()->GetContentIndex(), aChar.End()->GetContentIndex()); ++ const sal_Int16 nEsc = GetPositionEscapement(it->second.get(), aSize.Get(RES_CHRATR_FONTSIZE).GetHeight() / 20.0); ++ if (!nEsc) ++ return; ++ // the run's text after it keeps its own position ++ aChar.GetDoc().getIDocumentContentOperations().InsertPoolItem( ++ aChar, SvxEscapementItem(nEsc, 100, RES_CHRATR_ESCAPEMENT), SetAttrMode::DONTEXPAND); ++} ++ + void DomainMapper_Impl::appendOLE( const OUString& rStreamName, const std::shared_ptr& pOLEHandler ) + { + try diff --git a/patches/libreoffice/0050-writer-eq-field-parser.patch b/patches/libreoffice/0050-writer-eq-field-parser.patch new file mode 100644 index 0000000..b6402ec --- /dev/null +++ b/patches/libreoffice/0050-writer-eq-field-parser.patch @@ -0,0 +1,454 @@ +Writer: read Word EQ field commands as StarMath + +A parser for Word EQ field commands: \f fraction, \r radical, +\s super- and subscript, \b brackets, \l list, \a array, +\i integral, sum and product; \x and \o keep their content +without the box or overlay, \d moves forward only; \ai and +\di only space lines. Switches ignore case, as Word reads them. +Each command character keeps its vertAlign; one script stays one +script across style changes. Italic letters become identifiers, +which StarMath and OMML both draw italic. The next patch uses it. + +--- a/sw/Library_sw_writerfilter.mk ++++ b/sw/Library_sw_writerfilter.mk +@@ -89,6 +89,7 @@ $(eval $(call gb_Library_add_exception_objects,sw_writerfilter,\ + sw/source/writerfilter/dmapper/DomainMapperTableHandler \ + sw/source/writerfilter/dmapper/DomainMapperTableManager \ + sw/source/writerfilter/dmapper/DomainMapper_Impl \ ++ sw/source/writerfilter/dmapper/EqField \ + sw/source/writerfilter/dmapper/domainmapperfactory \ + sw/source/writerfilter/dmapper/FFDataHandler \ + sw/source/writerfilter/dmapper/FontTable \ +--- /dev/null ++++ b/sw/source/writerfilter/dmapper/EqField.hxx +@@ -0,0 +1,30 @@ ++/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ ++/* ++ * This file is part of the LibreOffice project. ++ * ++ * This Source Code Form is subject to the terms of the Mozilla Public ++ * License, v. 2.0. If a copy of the MPL was not distributed with this ++ * file, You can obtain one at http://mozilla.org/MPL/2.0/. ++ */ ++ ++#pragma once ++ ++#include ++ ++#include ++ ++namespace writerfilter::dmapper ++{ ++/// How one character of a field command looks. ++struct EqLook ++{ ++ bool bItalic = false; ++ /// -1 subscript, 0 baseline, 1 superscript. ++ sal_Int8 nScript = 0; ++}; ++ ++/// A Word EQ field as StarMath; empty if unreadable. ++OUString EqToStarMath(std::u16string_view rCommand, const std::vector& rLooks); ++} ++ ++/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ +--- /dev/null ++++ b/sw/source/writerfilter/dmapper/EqField.cxx +@@ -0,0 +1,397 @@ ++/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ ++/* ++ * This file is part of the LibreOffice project. ++ * ++ * This Source Code Form is subject to the terms of the Mozilla Public ++ * License, v. 2.0. If a copy of the MPL was not distributed with this ++ * file, You can obtain one at http://mozilla.org/MPL/2.0/. ++ */ ++ ++#include "EqField.hxx" ++ ++#include ++#include ++#include ++#include ++ ++#include ++ ++namespace writerfilter::dmapper ++{ ++namespace ++{ ++/// Word's EQ switches as StarMath. ++class EqParser ++{ ++ std::u16string_view m_aCommand; ++ const std::vector& m_rLooks; ++ size_t m_nPos = 0; ++ bool m_bFailed = false; ++ ++public: ++ EqParser(std::u16string_view aCommand, const std::vector& rLooks) ++ : m_aCommand(aCommand) ++ , m_rLooks(rLooks) ++ { ++ } ++ ++ OUString parse() ++ { ++ skipSpaces(); ++ if (rtl::toAsciiLowerCase(at(m_nPos)) != 'e' || rtl::toAsciiLowerCase(at(m_nPos + 1)) != 'q') ++ return OUString(); ++ m_nPos += 2; ++ skipSpaces(); ++ OUString aMath = element(false); ++ return m_bFailed ? OUString() : aMath; ++ } ++ ++private: ++ sal_Unicode at(size_t nPos) const { return nPos < m_aCommand.size() ? m_aCommand[nPos] : 0; } ++ ++ EqLook look(size_t nPos) const { return nPos < m_rLooks.size() ? m_rLooks[nPos] : EqLook(); } ++ ++ void skipSpaces() ++ { ++ while (at(m_nPos) == ' ') ++ ++m_nPos; ++ } ++ ++ void fail(const char* pWhy) ++ { ++ SAL_WARN("writerfilter.dmapper", "EQ field: " << pWhy << " at " << m_nPos); ++ m_bFailed = true; ++ } ++ ++ static OUString quoted(std::u16string_view aText) ++ { ++ OUStringBuffer aBuf("\""); ++ for (sal_Unicode c : aText) ++ { ++ if (c == '"') ++ aBuf.append('\\'); ++ aBuf.append(c); ++ } ++ return aBuf.append('"').makeStringAndClear(); ++ } ++ ++ /// Italic letters as identifiers, which OMML keeps italic. ++ static void italic(std::vector& rRuns, std::u16string_view aText) ++ { ++ OUStringBuffer aOther; ++ auto other = [&]() { ++ if (!aOther.isEmpty()) ++ rRuns.push_back("ital " + quoted(aOther.makeStringAndClear())); ++ }; ++ for (sal_Unicode c : aText) ++ { ++ if (!u_isalpha(c)) ++ { ++ aOther.append(c); ++ continue; ++ } ++ other(); ++ rRuns.push_back(OUString(c)); ++ } ++ other(); ++ } ++ ++ /// A bracket character's StarMath name; empty if unknown. ++ static OUString brace(sal_Unicode c, bool bLeft) ++ { ++ static const std::map aNames{ ++ { '(', u"("_ustr }, { ')', u")"_ustr }, { '[', u"["_ustr }, ++ { ']', u"]"_ustr }, { '{', u"lbrace"_ustr }, { '}', u"rbrace"_ustr }, ++ { '<', u"langle"_ustr }, { '>', u"rangle"_ustr }, { 0x27E8, u"langle"_ustr }, ++ { 0x27E9, u"rangle"_ustr }, { 0x230A, u"lfloor"_ustr }, { 0x230B, u"rfloor"_ustr }, ++ { 0x2308, u"lceil"_ustr }, { 0x2309, u"rceil"_ustr } ++ }; ++ if (c == '|') ++ return bLeft ? u"lline"_ustr : u"rline"_ustr; ++ if (c == 0x2016) ++ return bLeft ? u"ldline"_ustr : u"rdline"_ustr; ++ auto it = aNames.find(c); ++ return it == aNames.end() ? OUString() : it->second; ++ } ++ ++ /// A big operator character's StarMath name; empty if unknown. ++ static OUString bigOperator(sal_Unicode c) ++ { ++ static const std::map aNames{ ++ { 0x2211, u"sum"_ustr }, { 0x220F, u"prod"_ustr }, { 0x2210, u"coprod"_ustr }, ++ { 0x222B, u"int"_ustr }, { 0x222C, u"iint"_ustr }, { 0x222D, u"iiint"_ustr }, ++ { 0x222E, u"lint"_ustr }, { 0x222F, u"llint"_ustr }, { 0x2230, u"lllint"_ustr } ++ }; ++ auto it = aNames.find(c); ++ return it == aNames.end() ? OUString() : it->second; ++ } ++ ++ /// Puts `aScript` above or below the last atom. ++ static void attach(std::vector& rAtoms, sal_Int8 nScript, const OUString& aScript) ++ { ++ // empty text, not a placeholder ++ OUString aBase = u"\"\""_ustr; ++ if (!rAtoms.empty()) ++ { ++ aBase = "{" + rAtoms.back() + "}"; ++ rAtoms.pop_back(); ++ } ++ rAtoms.push_back(aBase + (nScript > 0 ? u"^{" : u"_{") + aScript + "}"); ++ } ++ ++ /// Text up to an argument's end, or all. ++ OUString element(bool bArgument) ++ { ++ std::vector aAtoms; ++ // runs that share one script position ++ std::vector aGroup; ++ OUStringBuffer aText; ++ EqLook aLook; ++ auto piece = [&]() { ++ if (aText.isEmpty()) ++ return; ++ OUString aRun = aText.makeStringAndClear(); ++ if (aLook.bItalic) ++ italic(aGroup, aRun); ++ else ++ aGroup.push_back(quoted(aRun)); ++ }; ++ auto flush = [&]() { ++ piece(); ++ if (aGroup.empty()) ++ return; ++ if (aLook.nScript == 0) ++ aAtoms.insert(aAtoms.end(), aGroup.begin(), aGroup.end()); ++ else ++ { ++ OUStringBuffer aScript; ++ for (const OUString& rRun : aGroup) ++ aScript.append((aScript.isEmpty() ? u"" : u" ") + rRun); ++ attach(aAtoms, aLook.nScript, aScript.makeStringAndClear()); ++ } ++ aGroup.clear(); ++ }; ++ auto add = [&](sal_Unicode c, EqLook aNew) { ++ if (aNew.nScript != aLook.nScript) ++ flush(); ++ else if (aNew.bItalic != aLook.bItalic) ++ piece(); ++ aLook = aNew; ++ aText.append(c); ++ }; ++ int nDepth = 0; ++ while (m_nPos < m_aCommand.size() && !m_bFailed) ++ { ++ sal_Unicode c = m_aCommand[m_nPos]; ++ if (bArgument && nDepth == 0 && (c == ',' || c == ')')) ++ break; ++ if (c == '\\') ++ { ++ sal_Unicode cNext = at(m_nPos + 1); ++ // general switches such as \* MERGEFORMAT follow the equation ++ if (!bArgument && nDepth == 0 ++ && (cNext == '*' || cNext == '#' || cNext == '@' || cNext == '!')) ++ { ++ while (!aText.isEmpty() && aText[aText.getLength() - 1] == ' ') ++ aText.setLength(aText.getLength() - 1); ++ break; ++ } ++ if (rtl::isAsciiAlpha(cNext)) ++ { ++ flush(); ++ ++m_nPos; ++ command(aAtoms); ++ continue; ++ } ++ if (cNext != 0) ++ { ++ add(cNext, look(m_nPos + 1)); ++ m_nPos += 2; ++ continue; ++ } ++ } ++ if (c == '(') ++ ++nDepth; ++ else if (c == ')') ++ --nDepth; ++ add(c, look(m_nPos)); ++ ++m_nPos; ++ } ++ flush(); ++ OUStringBuffer aMath; ++ for (const OUString& rAtom : aAtoms) ++ aMath.append((aMath.isEmpty() ? u"" : u" ") + rAtom); ++ return aMath.makeStringAndClear(); ++ } ++ ++ /// `(e, e, ...)`. ++ std::vector arguments() ++ { ++ std::vector aArgs; ++ skipSpaces(); ++ if (at(m_nPos) != '(') ++ { ++ fail("no arguments"); ++ return aArgs; ++ } ++ ++m_nPos; ++ while (!m_bFailed) ++ { ++ aArgs.push_back(element(true)); ++ sal_Unicode c = at(m_nPos++); ++ if (c == ')') ++ break; ++ if (c != ',') ++ fail("unclosed arguments"); ++ } ++ return aArgs; ++ } ++ ++ /// The switch after `\`, its options and arguments. ++ void command(std::vector& rAtoms) ++ { ++ const sal_Unicode cCommand = rtl::toAsciiLowerCase(m_aCommand[m_nPos++]); ++ std::map aOptions; ++ for (skipSpaces(); at(m_nPos) == '\\' && rtl::isAsciiAlpha(at(m_nPos + 1)); skipSpaces()) ++ { ++ // switches ignore case, as Word reads them ++ OUString aName = OUString(m_aCommand.substr(m_nPos + 1, 2)).toAsciiLowerCase(); ++ m_nPos += 3; ++ if (aName == "lc" || aName == "rc" || aName == "bc" || aName == "fc" || aName == "vc") ++ { ++ if (at(m_nPos) == '\\') ++ ++m_nPos; ++ aOptions[aName] = OUString(at(m_nPos++)); ++ continue; ++ } ++ skipSpaces(); ++ size_t nStart = m_nPos; ++ while (rtl::isAsciiDigit(at(m_nPos))) ++ ++m_nPos; ++ aOptions[aName] = OUString(m_aCommand.substr(nStart, m_nPos - nStart)); ++ } ++ std::vector aArgs = arguments(); ++ if (m_bFailed) ++ return; ++ auto join = [&](std::u16string_view aBetween) { ++ OUStringBuffer aBuf; ++ for (size_t i = 0; i < aArgs.size(); ++i) ++ aBuf.append((i ? aBetween : u"") + OUString("{" + aArgs[i] + "}")); ++ return aBuf.makeStringAndClear(); ++ }; ++ switch (cCommand) ++ { ++ case 'f': ++ if (aArgs.size() != 2) ++ return fail("a fraction needs two parts"); ++ rAtoms.push_back("{" + aArgs[0] + "} over {" + aArgs[1] + "}"); ++ break; ++ case 'r': ++ if (aArgs.size() == 1) ++ rAtoms.push_back("sqrt{" + aArgs[0] + "}"); ++ else if (aArgs.size() == 2) ++ rAtoms.push_back("nroot{" + aArgs[0] + "}{" + aArgs[1] + "}"); ++ else ++ return fail("a radical needs one or two parts"); ++ break; ++ case 's': ++ // \ai and \di only add line spacing ++ if (aOptions.contains(u"up"_ustr)) ++ attach(rAtoms, 1, join(u" ")); ++ else if (aOptions.contains(u"do"_ustr)) ++ attach(rAtoms, -1, join(u" ")); ++ else ++ rAtoms.push_back("stack{" + join(u" # ") + "}"); ++ break; ++ case 'b': ++ { ++ static const std::map aClosing{ ++ { '(', ')' }, { '[', ']' }, { '{', '}' }, { '<', '>' } ++ }; ++ auto option = [&](const OUString& rKey) { ++ auto it = aOptions.find(rKey); ++ return it == aOptions.end() || it->second.isEmpty() ? sal_Unicode(0) ++ : it->second[0]; ++ }; ++ const sal_Unicode cBoth = option(u"bc"_ustr); ++ sal_Unicode cLeft = option(u"lc"_ustr); ++ sal_Unicode cRight = option(u"rc"_ustr); ++ if (!cLeft) ++ cLeft = cBoth ? cBoth : '('; ++ if (!cRight) ++ { ++ auto it = aClosing.find(cBoth); ++ cRight = !cBoth ? ')' : it != aClosing.end() ? it->second : cBoth; ++ } ++ const OUString aLeft = brace(cLeft, true); ++ const OUString aRight = brace(cRight, false); ++ if (aLeft.isEmpty() || aRight.isEmpty()) ++ return fail("an unknown bracket"); ++ rAtoms.push_back("left " + aLeft + " " + join(u" \",\" ") + " right " + aRight); ++ break; ++ } ++ case 'l': ++ rAtoms.push_back(join(u" \",\" ")); ++ break; ++ case 'a': ++ { ++ sal_Int32 nColumns = std::max(1, aOptions[u"co"_ustr].toInt32()); ++ OUString aAlign = aOptions.contains(u"al"_ustr) ? u"alignl "_ustr ++ : aOptions.contains(u"ar"_ustr) ? u"alignr "_ustr ++ : OUString(); ++ // a short last row is padded, never dropped ++ while (aArgs.size() % nColumns) ++ aArgs.emplace_back(); ++ OUStringBuffer aBuf("matrix{"); ++ for (size_t i = 0; i < aArgs.size(); ++i) ++ { ++ if (i) ++ aBuf.append(i % nColumns ? u" # " : u" ## "); ++ aBuf.append(aAlign + "{" + aArgs[i] + "}"); ++ } ++ rAtoms.push_back(aBuf.append("}").makeStringAndClear()); ++ break; ++ } ++ case 'i': ++ { ++ if (aArgs.size() != 3) ++ return fail("an integral needs three parts"); ++ OUString aOperator = aOptions.contains(u"su"_ustr) ? u"sum"_ustr ++ : aOptions.contains(u"pr"_ustr) ? u"prod"_ustr ++ : u"int"_ustr; ++ // a given operator character wins ++ for (const OUString& rKey : { u"fc"_ustr, u"vc"_ustr }) ++ { ++ auto it = aOptions.find(rKey); ++ if (it == aOptions.end()) ++ continue; ++ aOperator = it->second.isEmpty() ? OUString() : bigOperator(it->second[0]); ++ if (aOperator.isEmpty()) ++ return fail("an unknown operator"); ++ } ++ rAtoms.push_back(aOperator + " from{" + aArgs[0] + "} to{" + aArgs[1] + "} {" ++ + aArgs[2] + "}"); ++ break; ++ } ++ case 'x': ++ case 'o': ++ rAtoms.push_back(join(u" ")); ++ break; ++ case 'd': ++ if (aOptions.contains(u"fo"_ustr)) ++ rAtoms.push_back(u"~"_ustr); ++ break; ++ default: ++ fail("an unknown switch"); ++ } ++ } ++}; ++} ++ ++OUString EqToStarMath(std::u16string_view rCommand, const std::vector& rLooks) ++{ ++ return EqParser(rCommand, rLooks).parse(); ++} ++} ++ ++/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/patches/libreoffice/0051-writer-eq-fields-as-formulas.patch b/patches/libreoffice/0051-writer-eq-fields-as-formulas.patch new file mode 100644 index 0000000..648dcfc --- /dev/null +++ b/patches/libreoffice/0051-writer-eq-fields-as-formulas.patch @@ -0,0 +1,169 @@ +Writer: Word EQ fields open as formulas + +DOCX import read only EQ's \o, \s\do and ruby forms; every other +EQ field, such as \f(a,b) for a fraction, vanished (Gitea #9). +The import now turns an EQ field into a Math object through +the parser of the previous patch. Each command character keeps +its run's italic and vertAlign, as Word draws them (a with a +subscript 10 is a_{10}); the formula takes the field's font +size. A command the parser cannot read shows its field code as +text, so nothing vanishes and the failure stays visible. Only a +whole plain \s\do N(text) field stays lowered text, as before; +the old reader dropped anything after its first bracket. + +--- a/sw/source/writerfilter/dmapper/DomainMapper_Impl.hxx ++++ b/sw/source/writerfilter/dmapper/DomainMapper_Impl.hxx +@@ -26,6 +26,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -50,6 +51,7 @@ + #include "ThemeHandler.hxx" + #include "GraphicImport.hxx" + #include "OLEHandler.hxx" ++#include "EqField.hxx" + #include "FFDataHandler.hxx" + #include "SmartTagHandler.hxx" + #include "FormControlHelper.hxx" +@@ -303,6 +305,8 @@ class FieldContext : public virtual SvRefBase + // 0: Normal, inserted command line + // 1: Deleted command line + OUString m_sCommand[2]; ++ /// The look of each inserted command character, for EQ fields. ++ std::vector m_aCommandLooks; + OUString m_sResult; + OUString m_sVariableValue; + std::optional m_eFieldId; +@@ -346,6 +350,12 @@ public: + + void AppendCommand(std::u16string_view rPart); + /// Deleted command text has no look. ++ void AppendLooks(sal_Int32 nCount, EqLook aLook) ++ { ++ if (!m_bCommandType) ++ m_aCommandLooks.insert(m_aCommandLooks.end(), nCount, aLook); ++ } ++ const std::vector& GetCommandLooks() const { return m_aCommandLooks; } + const OUString& GetCommand() const {return m_sCommand[m_bCommandType]; } + bool GetCommandIsEmpty(bool bType) const { return m_sCommand[bType].isEmpty(); } + void SetCommandType(bool cType) { m_bCommandType = cType; } +@@ -846,6 +856,9 @@ public: + void moveWithRun(const css::uno::Reference& xContent); + void appendOLE( const OUString& rStreamName, const std::shared_ptr& pOleHandler ); + void appendStarMath( const Value& v); ++ void appendFormula(const css::uno::Reference& xFormula); ++ /// A Word EQ field as a formula object. ++ void appendEqFormula(const FieldContextPtr& pContext); + void adjustLastPara(sal_Int8 nAlign); + rtl::Reference appendTextSectionAfter(css::uno::Reference const & xBefore); + +--- a/sw/source/writerfilter/dmapper/DomainMapper_Impl.cxx ++++ b/sw/source/writerfilter/dmapper/DomainMapper_Impl.cxx +@@ -92,6 +92,9 @@ + #include + #include + #include ++#include ++#include ++#include + #include + #include + #include +@@ -3815,19 +3818,44 @@ void DomainMapper_Impl::appendOLE( const OUString& rStreamName, const std::share + + } + ++void DomainMapper_Impl::appendEqFormula(const FieldContextPtr& pContext) ++{ ++ // the looks index the untrimmed command ++ const OUString aMath = EqToStarMath(pContext->GetCommand(), pContext->GetCommandLooks()); ++ if (aMath.isEmpty()) ++ { ++ // an unreadable field shows its code, never vanishes ++ appendTextPortion(pContext->GetCommand().trim(), pContext->getProperties()); ++ return; ++ } ++ comphelper::EmbeddedObjectContainer aContainer; ++ OUString aName; ++ uno::Reference xFormula = aContainer.CreateEmbeddedObject( ++ SvGlobalName(SO3_SM_CLASSID).GetByteSequence(), aName, m_aBaseUrl); ++ uno::Reference xMath(xFormula->getComponent(), uno::UNO_QUERY_THROW); ++ xMath->setPropertyValue(u"Formula"_ustr, uno::Any(aMath)); ++ double fHeight = 0; ++ if (GetAnyProperty(PROP_CHAR_HEIGHT, pContext->getProperties()) >>= fHeight) ++ xMath->setPropertyValue(u"BaseFontHeight"_ustr, uno::Any(sal_Int16(std::round(fHeight)))); ++ appendFormula(xFormula); ++} ++ + void DomainMapper_Impl::appendStarMath( const Value& val ) + { + uno::Reference< embed::XEmbeddedObject > formula; + val.getAny() >>= formula; +- if( !formula.is() ) +- return; ++ if( formula.is() ) ++ appendFormula(formula); ++} + ++void DomainMapper_Impl::appendFormula(const uno::Reference& formula) ++{ + try + { + rtl::Reference xStarMath = m_xTextDocument->createTextEmbeddedObject(); + + xStarMath->setPropertyValue(getPropertyName( PROP_EMBEDDED_OBJECT ), +- val.getAny()); ++ uno::Any(formula)); + // tdf#66405: set zero margins for embedded object + xStarMath->setPropertyValue(getPropertyName( PROP_LEFT_MARGIN ), + uno::Any(sal_Int32(0))); +@@ -6599,11 +6627,20 @@ void DomainMapper_Impl::AppendFieldCommand(OUString const & rPartOfCommand) + { + // Set command line type: normal or deleted + pContext->SetCommandType(m_bTextDeleted); ++ PropertyMapPtr pCharContext = GetTopContextOfType(CONTEXT_CHARACTER); + if (pContext->GetCommand().isEmpty()) + { +- pContext->getProperties()->InsertProps(GetTopContextOfType(CONTEXT_CHARACTER)); ++ pContext->getProperties()->InsertProps(pCharContext); + } + pContext->AppendCommand( rPartOfCommand ); ++ EqLook aLook; ++ awt::FontSlant eSlant = awt::FontSlant_NONE; ++ aLook.bItalic = (GetAnyProperty(PROP_CHAR_POSTURE, pCharContext) >>= eSlant) ++ && eSlant == awt::FontSlant_ITALIC; ++ sal_Int16 nEscapement = 0; ++ GetAnyProperty(PROP_CHAR_ESCAPEMENT, pCharContext) >>= nEscapement; ++ aLook.nScript = nEscapement > 0 ? 1 : nEscapement < 0 ? -1 : 0; ++ pContext->AppendLooks(rPartOfCommand.getLength(), aLook); + } + } + +@@ -8209,7 +8246,13 @@ void DomainMapper_Impl::CloseFieldCommand() + sal_Int32 nSpaceIndex = aCommand.indexOf(' '); + if(nSpaceIndex > 0) + aCommand = o3tl::trim(aCommand.subView(nSpaceIndex)); +- if (aCommand.startsWith("\\s")) ++ // only a whole plain lowered run stays text ++ sal_Int32 nOpen = aCommand.indexOf('('); ++ bool bLowered = aCommand.startsWith("\\s\\do") && nOpen > 0 ++ && aCommand.indexOf(')') == aCommand.getLength() - 1 ++ && aCommand.indexOf('(', nOpen + 1) < 0 ++ && aCommand.indexOf('\\', 5) < 0; ++ if (bLowered) + { + aCommand = aCommand.copy(2); + if (aCommand.startsWith("\\do")) +@@ -8236,6 +8279,8 @@ void DomainMapper_Impl::CloseFieldCommand() + { + handleRubyEQField(pContext); + } ++ else ++ appendEqFormula(pContext); + } + } + break; diff --git a/tasks/TASKS.md b/tasks/TASKS.md index 6e02d86..078419a 100644 --- a/tasks/TASKS.md +++ b/tasks/TASKS.md @@ -289,7 +289,7 @@ ## R8 内容配色、Gitea #8 与 #9、ElyMark 公式(用户 2026-10-18 报;审查改为 gpt-6.1-sol,effort xhigh,fast) - [x] R8.1 深色外观下内容照文件原色(§5,用户定,取代 2026-10-04 的“内容区随系统深浅色”):ElyPDF 页面不反相;ElyDoc 页面、ElyTable 单元格照文件的颜色(白底即白底),ElyShow 已是如此(`ely.xcd` 只留浅色方案,每个视图首帧发一次;MuPDF 不再反相;Windows 真窗口深色外观下四个应用都是白纸;测试以深色档案起内核、视图须把页面画白,去掉发送即失败 — 第 1 轮:`theme.sh` 仍要深色网格、测试的内核本就浅色;第 2 轮:两处注释超长;第 3 轮 PASS) -- [ ] R8.2 Gitea #9 公式不兼容(§7):附件 math.docx 的键盘公式、OMML、MathType(OLE)、EQ 域、上下标、图片公式逐类与 Word 对照,查明各类差异的根因并修;修不了的写明原因与证据 +- [x] R8.2 Gitea #9 公式不兼容(§7):附件 math.docx 的键盘公式、OMML、MathType(OLE)、EQ 域、上下标、图片公式逐类与 Word 对照,查明各类差异的根因并修;修不了的写明原因与证据(补丁 0049:运行的 `w:position` 随锚字符移动图片与 OLE;0050、0051:EQ 域转为公式对象,读不通的显示域代码;`word.rs` 三项测试;第 3 轮四处修后未再审查) - [ ] R8.3 Gitea #8 工作表过多时标签条不滚动(§9):活动工作表变化时标签条滚动,使该标签完整可见;被裁到窗口外的工作表可经标签条到达 - [ ] R8.4 ElyMark 公式(§14):行内公式排版(原只显示着色的 TeX 原文);`\(…\)` 读作行内公式,`\[…\]` 读作块公式 - [ ] R8.5 答复 #8、#9,修好的关闭;`agents.md` 随之 diff --git a/tasks/reviews/R8.2-r1.md b/tasks/reviews/R8.2-r1.md new file mode 100644 index 0000000..509150b --- /dev/null +++ b/tasks/reviews/R8.2-r1.md @@ -0,0 +1,11 @@ +FAIL + +- patches/libreoffice/0050-writer-eq-fields-as-formulas.patch:92 — [P1] Trimming the command without trimming its character-format vector shifts every formatting index. Reproduced against the rebuilt Windows worker by prepending one space to the EQ instruction in `word.docx`: the expected italic `a₁₀/a₃` becomes `a1₀/a3`, with the wrong characters italicized and the denominator's subscript lost. The parser already skips leading spaces; pass the original command, or adjust both inputs identically, and add this regression. Evidence: `.work/r82-r1/probe.log` and `regression.log`. + +- patches/libreoffice/0050-writer-eq-fields-as-formulas.patch:395 — [P1] Secondary switch names remain case-sensitive, although Word field switches are case-insensitive ([field syntax §2.16.1](https://www.durusau.net/publications/Muscular-WordProcessingML.pdf)). Reproduced: `EQ \I\SU(1,5,x)` imports as an integral, whereas `EQ \i\su(1,5,x)` imports as a sum; `\A\CO2(a,b,c,d)` becomes a four-row column instead of a two-by-two array. This silently changes mathematical meaning. Normalize secondary switch names before interpreting them and test uppercase and mixed-case spellings. Evidence: `.work/r82-r1/probe.log`. + +- patches/libreoffice/0050-writer-eq-fields-as-formulas.patch:266 — [P2] Parse failures disappear without the promised diagnostic in the shipped kernel. `fail()` only uses `SAL_WARN`, which this Windows release build compiles out (`ENABLE_SAL_LOG` is empty); the empty result is then discarded at lines 93–94. Reproduced with `EQ \f(a)`: loading and saving both succeed, the field disappears, and worker stderr contains no EQ diagnostic even with `SAL_LOG=+INFO.writerfilter.dmapper`. This violates the error-exposure rule and the R8.2 documentation's logging claim. Propagate failure through an import warning/error observable in release builds and add a failure-path test. Evidence: `.work/r82-r1/regression.log`, `worker.log`, and `review.log`. + +- patches/libreoffice/0050-writer-eq-fields-as-formulas.patch:95 — [P2] The new `SAL_INFO` statement logs the complete formula, including document text. With a logging-enabled kernel and `SAL_LOG=+INFO.writerfilter.dmapper` or a corresponding `SAL_LOG_FILE`, opening a field such as `EQ \f(PrivateAmount,PrivateDivisor)` writes both operands into the native diagnostic log. This violates rule 4's prohibition on logging document contents. Remove the formula payload from the diagnostic. + +- patches/libreoffice/0050-writer-eq-fields-as-formulas.patch:517 — [P3] This newly added file has 517 lines, exceeding the explicit 500-line file limit. Split the diff into smaller ordered patches while preserving all changes and their application order. diff --git a/tasks/reviews/R8.2-r2.md b/tasks/reviews/R8.2-r2.md new file mode 100644 index 0000000..58fde69 --- /dev/null +++ b/tasks/reviews/R8.2-r2.md @@ -0,0 +1,9 @@ +FAIL + +- patches/libreoffice/0050-writer-eq-field-parser.patch:327 — [P1] An incomplete final array row silently loses operands. Reproduced with `EQ \a\co2(a,b,c)`: the generated StarMath has unequal row lengths, the actual MathML contains only `a` and `b`, and saving DOCX writes only those two operands into OMML. The annotation still contains `c`, so checking annotation text alone misses the loss. Pad the final row or reject the conversion visibly; test actual rendered/exported formula content. Evidence: `.work/r82-r2/edge-probe.log`, `array_three`. + +- patches/libreoffice/0050-writer-eq-field-parser.patch:340 — [P1] The parser consumes custom operator switches but silently substitutes an integral. Reproduced with `EQ \i\fc\∑(1,5,x)`: the resulting formula is `int from{"1"} to{"5"} {"x"}`, and its MathML operator is `∫`. The specified summation operator has changed mathematical meaning. Honor the supplied symbol or fail parsing so the original field code remains visible; cover both `\fc` and `\vc`. These switches specify the operator character in [ECMA-376 Part 4 §14.10.4.6](https://ecma-international.org/wp-content/uploads/ECMA-376-4_5th_edition_december_2016.zip). Evidence: `.work/r82-r2/edge-probe.log`, `operator_sigma`. + +- patches/libreoffice/0050-writer-eq-field-parser.patch:141 — [P1] Unrecognized bracket characters silently become invisible delimiters. Reproduced with `EQ \b\lc\⌊\rc\⌋(x)`: import succeeds as `left none {"x"} right none`; the actual MathML contains only `x`, losing the floor operation. The character options are defined by [ECMA-376 Part 4 §14.10.4.6](https://ecma-international.org/wp-content/uploads/ECMA-376-4_5th_edition_december_2016.zip). Preserve the delimiters or report the command unreadable instead of replacing them with `none`, and add a regression. Evidence: `.work/r82-r2/edge-probe.log`, `floor`. + +- patches/libreoffice/0050-writer-eq-field-parser.patch:194 — [P2] General field switches are imported as visible equation text. Reproduced with `EQ \f(a,b) \* MERGEFORMAT`: the fraction acquires the literal suffix ` * MERGEFORMAT` in both StarMath and MathML; `\* CHARFORMAT` behaves likewise. [Microsoft documents these as formatting switches](https://support.microsoft.com/en-au/word/format-field-results), with MERGEFORMAT added by default by the field dialog. Parse general switches separately from equation content and add regressions asserting they do not appear in the formula result. Evidence: `.work/r82-r2/probe.log`, `mergeformat` and `charformat`. diff --git a/tasks/reviews/R8.2-r3.md b/tasks/reviews/R8.2-r3.md new file mode 100644 index 0000000..caf4173 --- /dev/null +++ b/tasks/reviews/R8.2-r3.md @@ -0,0 +1,9 @@ +FAIL + +- patches/libreoffice/0051-writer-eq-fields-as-formulas.patch:149 — [P1] Fields beginning with lowercase `\s\do` still bypass the new parser and silently truncate the expression. `EQ \s\do2(x)+y` imports and saves as just `x`, losing `+y`; `EQ \s\do2(\f(a,b))` becomes the literal incomplete text `\f(a,b`. Uppercase equivalents retain the complete expressions. Route complete EQ expressions through the parser instead of this partial legacy reader, and cover both cases through DOCX save/reopen. Evidence: `.work/r82-r3/probe.log`, `lower_script_tail` and `lower_script_nested`. + +- patches/libreoffice/0050-writer-eq-field-parser.patch:191 — [P1] Changing italics within one subscript flushes and attaches another subscript to the already subscripted base. Reproduced with `EQ \f(a10,b)`, where both digits have `w:vertAlign="subscript"`, but only `1` is italic: import creates `(a₁)₀/b` with two nested MathML `msub` elements, and saved DOCX contains nested `m:sSub` objects. Both digits should remain in one subscript. Accumulate a contiguous script across font-style changes before attaching it, preserving the styles inside that script; add a rendered/exported structure regression. Evidence: `.work/r82-r3/probe.log`, `split_script` versus `merged_script`. + +- patches/libreoffice/0050-writer-eq-field-parser.patch:312 — [P1] `\ai` and `\di` are treated as aliases for raising and lowering an argument. These are paragraph spacing options in [Microsoft's EQ reference](https://support.microsoft.com/en-us/word/field-codes-eq-equation-field). `EQ x\s\ai4(y)` becomes `x` raised to `y`, while `EQ x\s\di4(y)` becomes `x` subscript `y`; the incorrect mathematical structures persist in DOCX. Handle spacing separately from script positioning, or preserve the field code visibly when unsupported, and test the actual MathML/OMML structure. Evidence: `.work/r82-r3/probe.log`, `script_ai` and `script_di`; `.work/r82-r3/spec.log`, page 194. + +- patches/libreoffice/0050-writer-eq-field-parser.patch:183 — [P2] The generated `ital "text"` representation loses italics when saved as DOCX. Reproduced using the unchanged `tests/fixtures/office/word.docx`: its fraction initially has two italic `a` characters, but exported OMML marks both as `m:nor` without italic formatting, and reopening shows both upright. The new tests only assert formula styling before DOCX saving. Preserve italic formatting through OMML serialization and assert the fixture's formula styles after save/reopen. Evidence: `.work/r82-r3/fixture-roundtrip.log` and `.work/r82-r3/regressions.log`. diff --git a/tests/fixtures/office/word.docx b/tests/fixtures/office/word.docx new file mode 100644 index 0000000000000000000000000000000000000000..ca1eb120edc92d283bffa560ae7b458be18713eb GIT binary patch literal 8349 zcmeHM%WoS+7i4R3|L?DU`{ht3gU1W-&vzFBUs7M*-q;SQcyD6Mb7wEZNo!CpSl}1+!bD!z zsBZ@j^J_)@*2dEKjDC4;HX_hqsV|CpUCPij3}M%)N5n)BQXgij0r!Z6TV69lV&5S( zYE0(y(}t~tj!P7(&&`%-m2A3FTigO&lvqw(p=+^rD66QGFm#zsB&cOHd?zJkJeDv4 zl+hYdXQ8+VYUoB!2{_YVK!3W>uchdihnC_0LDCu^$C*R5GU0NSc%Zn^47g)BfxYPg z)QEA4!7i2s)he@Tiypa!Jg}({;1bWBXw7(p`H96w2>BQGseU2g#~* zC@AAG4D^@{AB0UeK0OdjxHZE;*g2duqsUa8x|J-c$%G>rf?=n9A+v|z09105Q;5B$ zSqYqNyoPw<(Wx9=#0Of_Y!J7oPflTG8!)YAB!X)Ss5s!jkW)c<1DIW*Q1PgS3#D3~ zW*G*8Vf{)Nk55h_=cd_TRtzRA3Qgv_%%_^egxo0VP?vFZr8T-?x>aq6IA#H?`=)|uTgED=exP0mJw9UNf7F*l0? zzfoqOfx59*8$Fbnh^s3u#}&99UGfNd#bx0Vb6r#z#~L@OSD{!`fJoUSB&X7@Y zU8lB0Uc_uoM10$

KjqLt<`KxrbNC84W-MJOR$UVhmvKfHVk5h(j*qRT_924nQ@~ z0Nh1P(zq^SU6LiSkxG1|7ibe{8@mSm(RmN!R;b|EY()?TFb11C4H@JbsY=SbVjTEJ z$uoKeEpcK;`O(Ec*0}0A3w6RFcjA$HKvZZA${ji|u`IH2H}jYe-rhiu^~`0B-qkE* z*;=>90;<^|xPv;ep~}3La=ncpLy3SF7&-5bf zAf9lfqZ!1qgV?hpoAXTgm04BW`t#GT57J+KD`AjSEEh2O@^g#h<%{g-HU60h6$Q)v zPY&?V=Tu4ba{icRy{6q*T)(=ww6=a@eq+?Pqo<%o?#CBExD!tCi)ec9L49Pv*2f-w z2Mndc9tj`j8w5gQuvhfl-GgTvBeZ9S&mD)!lgrmi>u^1W$Fak3Uh&2rz~%IE zX?|nt8~dMMf4p~=ym;aJ)0ynU%Rl*l{Xt+hv%I)k`u58EAC*@Q9X^xk-$lo@_`3%l z?b%aLGf4Mgkqm$L4m|`G9bMTpVY>gQ$dK@@BR#s2Y4UXFwaGue5R>-_#5AWg{%uY_ ZzZ`Sw@