diff --git a/CHANGELOG.md b/CHANGELOG.md index f80accefd..217efb188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,10 @@ The release run heads these entries with the version and opens a fresh - `Text::set_style` and the edit op `setTextStyle {id, style}` write bold, italic, underline, strikethrough, highlight, colour and size onto a run of - an odf document. The other engines refuse it. + an odf, docx or pptx document. The other engines refuse it. + +- A `.docx` run's shading (`w:shd`) renders as its background where no + highlight paints over it. - A run that is both underlined and struck through renders both lines. The page wrote two `text-decoration` declarations, and the second replaced the diff --git a/docs/design/document-editing.md b/docs/design/document-editing.md index df1a34a4b..d812ce602 100644 --- a/docs/design/document-editing.md +++ b/docs/design/document-editing.md @@ -564,6 +564,7 @@ Each step is a pull request that builds and tests on its own. LibreOffice reopen of the saved file is the oracle. **Landed.** 3. **docx and pptx.** The run cut, the `w:rPr` order, `w:shd` on the read side, the `a:rPr` children. The same cases, over Word and Impress fixtures. + **Landed.** 4. **The browser.** `format()`, `onSelectionChange`, the four input types, the word rule for a collapsed caret, and a check page in `test/browser/text` asserting the log of each gesture. diff --git a/src/odr/internal/ooxml/ooxml_util.cpp b/src/odr/internal/ooxml/ooxml_util.cpp index 94c7d027a..58f26e0c7 100644 --- a/src/odr/internal/ooxml/ooxml_util.cpp +++ b/src/odr/internal/ooxml/ooxml_util.cpp @@ -6,10 +6,17 @@ #include #include +#include +#include #include +#include #include #include +#include #include +#include + +#include namespace odr::internal { @@ -99,21 +106,89 @@ ooxml::read_string_attribute(const pugi::xml_attribute attribute) { return attribute.value(); } -std::optional -ooxml::read_color_attribute(const pugi::xml_attribute attribute) { - // color codes from http://officeopenxml.com/WPtextShading.php - // rgb values suggested by chatgpt - static const std::unordered_map color_map{ - {"black", {0, 0, 0}}, {"blue", {0, 112, 192}}, - {"cyan", {0, 176, 240}}, {"darkBlue", {0, 32, 96}}, - {"darkCyan", {0, 97, 133}}, {"darkGray", {64, 64, 64}}, - {"darkGreen", {0, 128, 0}}, {"darkMagenta", {112, 48, 160}}, - {"darkRed", {192, 0, 0}}, {"darkYellow", {128, 96, 0}}, - {"green", {0, 176, 80}}, {"lightGray", {191, 191, 191}}, - {"magenta", {255, 0, 255}}, {"red", {255, 0, 0}}, - {"white", {255, 255, 255}}, {"yellow", {255, 255, 0}}, +namespace { + +/// [ECMA-376] 17.18.40 `ST_HighlightColor`, less `none`. The rgb values are +/// what Word paints for each name. +constexpr std::array, 16> + highlight_colors{{ + {"black", 0x000000}, + {"blue", 0x0070c0}, + {"cyan", 0x00b0f0}, + {"darkBlue", 0x002060}, + {"darkCyan", 0x006185}, + {"darkGray", 0x404040}, + {"darkGreen", 0x008000}, + {"darkMagenta", 0x7030a0}, + {"darkRed", 0xc00000}, + {"darkYellow", 0x806000}, + {"green", 0x00b050}, + {"lightGray", 0xbfbfbf}, + {"magenta", 0xff00ff}, + {"red", 0xff0000}, + {"white", 0xffffff}, + {"yellow", 0xffff00}, + }}; + +} // namespace + +pugi::xml_node +ooxml::insert_in_sequence(pugi::xml_node parent, const char *name, + const std::span order) { + const auto rank = [&](const std::string_view child_name) { + const auto it = std::ranges::find(order, child_name); + return it == std::end(order) + ? order.size() + : static_cast(it - std::begin(order)); }; + const std::size_t own_rank = rank(name); + for (const pugi::xml_node child : parent.children()) { + if (rank(child.name()) > own_rank) { + return parent.insert_child_before(name, child); + } + } + return parent.append_child(name); +} +std::string ooxml::hex_color(const Color &color) { + return fmt::format("{:06X}", color.rgb()); +} + +std::optional ooxml::highlight_name(const Color &color) { + const auto it = + std::ranges::find(highlight_colors, color.rgb(), + &std::pair::second); + if (it == std::end(highlight_colors)) { + return {}; + } + return it->first; +} + +double ooxml::points(const Measure &length) { + const std::string &unit = length.unit().name(); + if (unit == "pt") { + return length.magnitude(); + } + if (unit == "px") { + return length.magnitude() * 0.75; + } + if (unit == "in") { + return length.magnitude() * 72.0; + } + if (unit == "cm") { + return length.magnitude() * 72.0 / 2.54; + } + if (unit == "mm") { + return length.magnitude() * 72.0 / 25.4; + } + if (unit == "pc") { + return length.magnitude() * 12.0; + } + throw std::invalid_argument("no fixed size in points: " + length.to_string()); +} + +std::optional +ooxml::read_color_attribute(const pugi::xml_attribute attribute) { if (!attribute) { return {}; } @@ -121,9 +196,11 @@ ooxml::read_color_attribute(const pugi::xml_attribute attribute) { if (std::strcmp("auto", value) == 0 || std::strcmp("none", value) == 0) { return {}; } - if (const auto color_map_it = color_map.find(value); - color_map_it != std::end(color_map)) { - return color_map_it->second; + if (const auto it = + std::ranges::find(highlight_colors, std::string_view(value), + &std::pair::first); + it != std::end(highlight_colors)) { + return Color::from_rgb(it->second); } if (std::strlen(value) == 6) { const std::uint32_t color = std::strtoull(value, nullptr, 16); diff --git a/src/odr/internal/ooxml/ooxml_util.hpp b/src/odr/internal/ooxml/ooxml_util.hpp index ecd182c07..88c7e668c 100644 --- a/src/odr/internal/ooxml/ooxml_util.hpp +++ b/src/odr/internal/ooxml/ooxml_util.hpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -36,6 +37,18 @@ xml::NodeSpan write_text_nodes(pugi::xml_node parent, pugi::xml_node before, const std::string &text, std::string_view prefix); +/// Inserts a child @p name into @p parent at its place in the schema +/// sequence @p order; a child the sequence does not name ranks last. +pugi::xml_node insert_in_sequence(pugi::xml_node parent, const char *name, + std::span order); +/// `RRGGBB`, as `w:color/@w:val` and `a:srgbClr/@val` spell one. +std::string hex_color(const Color &color); +/// The `w:highlight` name of @p color, where it is one of the sixteen +/// ([ECMA-376] 17.18.40). +std::optional highlight_name(const Color &color); +/// @p length in points; refuses a unit that has no fixed size. +double points(const Measure &length); + std::optional read_string_attribute(pugi::xml_attribute); std::optional read_color_attribute(pugi::xml_attribute); std::optional read_half_point_attribute(pugi::xml_attribute); diff --git a/src/odr/internal/ooxml/presentation/AGENTS.md b/src/odr/internal/ooxml/presentation/AGENTS.md index af2878c8b..395934477 100644 --- a/src/odr/internal/ooxml/presentation/AGENTS.md +++ b/src/odr/internal/ooxml/presentation/AGENTS.md @@ -92,12 +92,13 @@ Coverage is in [`README.md`](README.md). Foundational gaps, roughly by value: 3. **Table cell styles unresolved.** Tables are wired (grid, spans, covered cells, column widths/row heights), but `a:tcPr` (fills, borders, margins) is not translated. -4. **Editing is text-content and the structure a text edit needs**, the same - surface `.docx` has: set a run's text, put a run beside one, remove an - element, and split, merge or insert a paragraph. The dom half is - `xml::TreeEditor`, shared with odf and ooxml text — only the tag names - differ, and those come from the nodes. No style editing, and no editing of - a shape, a picture or a table's furniture. +4. **Editing is runs, paragraphs and the seven text properties**, the same + surface `.docx` has. The dom half is `xml::TreeEditor`, shared with odf and + ooxml text — only the tag names differ. `text_set_style` cuts the `a:r` + around the run and writes the toggles and the size as `a:rPr` attributes, + the colour as `a:solidFill` and the highlight as `a:highlight`, each at its + place in the `CT_TextCharacterProperties` sequence. No other style editing, + and no editing of a shape, a picture or a table's furniture. `save` re-serialises the slide parts and copies the rest of the package through as bytes, so a part we never parsed survives untouched. The slides diff --git a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp index fc40afbd6..151a5d4b8 100644 --- a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp +++ b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp @@ -16,9 +16,12 @@ #include #include +#include +#include #include #include #include +#include namespace odr::internal::ooxml::presentation { @@ -189,6 +192,68 @@ const ElementRegistry &Document::element_registry() const { namespace { using TreeEditor = xml::TreeEditor; + +/// [ECMA-376] 21.1.2.3.9 `CT_TextCharacterProperties`: the children are a +/// sequence, one fill among them. +constexpr std::array run_property_order{ + "a:ln", "a:noFill", "a:solidFill", "a:gradFill", + "a:blipFill", "a:pattFill", "a:grpFill", "a:effectLst", + "a:effectDag", "a:highlight", "a:uLnTx", "a:uLn", + "a:uFillTx", "a:uFill", "a:latin", "a:ea", + "a:cs", "a:sym", "a:hlinkClick", "a:hlinkMouseOver", + "a:rtl", "a:extLst"}; + +constexpr std::array fill_names{"a:noFill", "a:solidFill", + "a:gradFill", "a:blipFill", + "a:pattFill", "a:grpFill"}; + +/// Writes the set fields of @p style into an `a:rPr`: the toggles and the +/// size as attributes, the colour and the highlight as children. +void write_run_properties(pugi::xml_node properties, const TextStyle &style) { + const auto attribute = [&](const char *name, const std::string &value) { + pugi::xml_attribute attr = properties.attribute(name); + if (!attr) { + attr = properties.append_attribute(name); + } + attr.set_value(value.c_str()); + }; + const auto solid = [&](const char *name, const Color &color) { + insert_in_sequence(properties, name, run_property_order) + .append_child("a:srgbClr") + .append_attribute("val") + .set_value(hex_color(color).c_str()); + }; + + if (style.font_weight.has_value()) { + attribute("b", *style.font_weight == FontWeight::bold ? "1" : "0"); + } + if (style.font_style.has_value()) { + attribute("i", *style.font_style == FontStyle::italic ? "1" : "0"); + } + if (style.font_underline.has_value()) { + attribute("u", *style.font_underline ? "sng" : "none"); + } + if (style.font_line_through.has_value()) { + attribute("strike", *style.font_line_through ? "sngStrike" : "noStrike"); + } + if (style.font_size.has_value()) { + attribute("sz", + std::to_string(std::lround(points(*style.font_size) * 100.0))); + } + if (style.font_color.has_value()) { + for (const char *name : fill_names) { + properties.remove_child(name); + } + solid("a:solidFill", *style.font_color); + } + if (style.background_color.has_value()) { + // there is no `none`: an absent highlight is none + properties.remove_child("a:highlight"); + if (style.background_color->alpha != 0) { + solid("a:highlight", *style.background_color); + } + } +} using xml::NodeSpan; using AdapterBase = internal::RegistryElementAdapter< @@ -310,6 +375,25 @@ class ElementAdapter final : public AdapterBase { void element_remove(const ElementIdentifier element_id) const override { TreeEditor(*m_registry).remove(element_id); } + /// Cuts the `a:r` around the run, each part keeping the `a:rPr`, and + /// writes into the part that holds the run. + void text_set_style(const ElementIdentifier element_id, + const TextStyle &style) const override { + const ElementIdentifier parent_id = element_parent(element_id); + if (parent_id == null_element_id || + element_type(parent_id) != ElementType::span) { + throw std::invalid_argument("the run sits in no a:r"); + } + const ElementIdentifier run_id = + TreeEditor(*m_registry).isolate(element_id); + pugi::xml_node run_node = get_node(run_id); + pugi::xml_node properties = run_node.child("a:rPr"); + if (!properties) { + // the schema wants it ahead of the text + properties = run_node.prepend_child("a:rPr"); + } + write_run_properties(properties, style); + } [[nodiscard]] ElementIdentifier paragraph_split(const ElementIdentifier element_id, diff --git a/src/odr/internal/ooxml/text/AGENTS.md b/src/odr/internal/ooxml/text/AGENTS.md index f0e4ef763..57bf77ab9 100644 --- a/src/odr/internal/ooxml/text/AGENTS.md +++ b/src/odr/internal/ooxml/text/AGENTS.md @@ -84,8 +84,15 @@ separately from its `ResolvedStyle` so an inherited one is seen. string and splices `w:t` (with `xml:space="preserve"` for spaces) / `w:tab` nodes into the live `m_document_xml`, updating the registry's node pointers. `save` re-zips the package, re-serialising **only** `word/document.xml` from the mutated -DOM; everything else is byte-copied. No structural edits; `save(path, password)` -throws (no re-encryption). +DOM; everything else is byte-copied. `save(path, password)` throws (no +re-encryption). + +`text_set_style` cuts the `w:r` around the run (`TreeEditor::isolate`) and +writes the seven text properties into its `w:rPr`. `CT_RPr` is a sequence +Word enforces, so each property lands at its rank (`run_property_order`) and +replaces an existing one whole. A highlight is `w:highlight` for one of the +sixteen names and a `w:shd` shading otherwise; the reader takes `w:shd` where +no highlight names a colour. ## Module layout @@ -106,8 +113,9 @@ Style/element coverage is in [`README.md`](README.md). Foundational gaps: nothing else; symbol-font bullets are mapped to Unicode by a small table and otherwise fall back to the level's default shape, since the private-use code points Word writes render only in Symbol / Wingdings. -2. **No structural editing**; save doesn't stream (buffers document.xml, re-zips - the whole package); no re-encryption on save. +2. **Editing is runs, paragraphs and the seven text properties**; save + doesn't stream (buffers document.xml, re-zips the whole package); no + re-encryption on save. 3. **Theme fonts unhandled.** `w:rFonts w:asciiTheme="minorHAnsi"` (etc.) is ignored — only literal `w:ascii` names are read (README example `Sample large docx.docx`). diff --git a/src/odr/internal/ooxml/text/ooxml_text_document.cpp b/src/odr/internal/ooxml/text/ooxml_text_document.cpp index 902fe3b4c..08a8e4db6 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_document.cpp +++ b/src/odr/internal/ooxml/text/ooxml_text_document.cpp @@ -11,10 +11,14 @@ #include #include +#include +#include #include #include +#include #include #include +#include namespace odr::internal::ooxml::text { @@ -163,6 +167,117 @@ using AdapterBase = internal::RegistryElementAdapter< abstract::TableCellAdapter, abstract::FrameAdapter, abstract::ImageAdapter>; using TreeEditor = xml::TreeEditor; + +/// [ECMA-376] 17.3.2.28 `CT_RPr`: a sequence, and Word refuses a file whose +/// run properties break it. +constexpr std::array run_property_order{ + "w:rStyle", + "w:rFonts", + "w:b", + "w:bCs", + "w:i", + "w:iCs", + "w:caps", + "w:smallCaps", + "w:strike", + "w:dstrike", + "w:outline", + "w:shadow", + "w:emboss", + "w:imprint", + "w:noProof", + "w:snapToGrid", + "w:vanish", + "w:webHidden", + "w:color", + "w:spacing", + "w:w", + "w:kern", + "w:position", + "w:sz", + "w:szCs", + "w:highlight", + "w:u", + "w:effect", + "w:bdr", + "w:shd", + "w:fitText", + "w:vertAlign", + "w:rtl", + "w:cs", + "w:em", + "w:lang", + "w:eastAsianLayout", + "w:specVanish", + "w:oMath"}; + +/// Replaces @p name whole at its place in the sequence, since a stale +/// `w:themeColor` would win over a new `w:val`. +pugi::xml_node set_run_property(pugi::xml_node properties, const char *name) { + properties.remove_child(name); + return insert_in_sequence(properties, name, run_property_order); +} + +/// Writes the set fields of @p style into a `w:rPr`, the complex-script +/// twins beside their siblings. +void write_run_properties(pugi::xml_node properties, const TextStyle &style) { + const auto toggle = [&](const char *name, const bool on) { + pugi::xml_node node = set_run_property(properties, name); + if (!on) { + node.append_attribute("w:val").set_value("0"); + } + }; + const auto value = [&](const char *name, const std::string &val) { + set_run_property(properties, name) + .append_attribute("w:val") + .set_value(val.c_str()); + }; + + if (style.font_weight.has_value()) { + const bool bold = *style.font_weight == FontWeight::bold; + toggle("w:b", bold); + toggle("w:bCs", bold); + } + if (style.font_style.has_value()) { + const bool italic = *style.font_style == FontStyle::italic; + toggle("w:i", italic); + toggle("w:iCs", italic); + } + if (style.font_line_through.has_value()) { + toggle("w:strike", *style.font_line_through); + // a double strike beside it would still draw + properties.remove_child("w:dstrike"); + } + if (style.font_color.has_value()) { + value("w:color", hex_color(*style.font_color)); + } + if (style.font_size.has_value()) { + const std::string half_points = + std::to_string(std::lround(points(*style.font_size) * 2.0)); + value("w:sz", half_points); + value("w:szCs", half_points); + } + if (style.background_color.has_value()) { + // a highlight paints over a shading, so only one of the two stays + const Color &color = *style.background_color; + const std::optional name = + color.alpha == 0 ? std::optional("none") + : highlight_name(color); + if (name.has_value()) { + value("w:highlight", std::string(*name)); + properties.remove_child("w:shd"); + } else { + pugi::xml_node shading = set_run_property(properties, "w:shd"); + shading.append_attribute("w:val").set_value("clear"); + shading.append_attribute("w:color").set_value("auto"); + shading.append_attribute("w:fill").set_value(hex_color(color).c_str()); + properties.remove_child("w:highlight"); + } + } + if (style.font_underline.has_value()) { + value("w:u", *style.font_underline ? "single" : "none"); + } +} using xml::NodeSpan; class ElementAdapter final : public AdapterBase { @@ -296,6 +411,24 @@ class ElementAdapter final : public AdapterBase { text_style(const ElementIdentifier element_id) const override { return get_intermediate_style(element_id).text_style; } + /// Cuts the `w:r` around the run, each part keeping the `w:rPr`, and + /// writes into the part that holds the run. + void text_set_style(const ElementIdentifier element_id, + const TextStyle &style) const override { + const ElementIdentifier parent_id = element_parent(element_id); + if (parent_id == null_element_id || + element_type(parent_id) != ElementType::span) { + throw std::invalid_argument("the run sits in no w:r"); + } + const ElementIdentifier run_id = + TreeEditor(*m_registry).isolate(element_id); + pugi::xml_node run_node = get_node(run_id); + pugi::xml_node properties = run_node.child("w:rPr"); + if (!properties) { + properties = run_node.prepend_child("w:rPr"); + } + write_run_properties(properties, style); + } [[nodiscard]] std::string link_href(const ElementIdentifier element_id) const override { diff --git a/src/odr/internal/ooxml/text/ooxml_text_style.cpp b/src/odr/internal/ooxml/text/ooxml_text_style.cpp index bcfe50db9..7cac687e7 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_style.cpp +++ b/src/odr/internal/ooxml/text/ooxml_text_style.cpp @@ -46,9 +46,14 @@ void resolve_text_style_(const pugi::xml_node node, TextStyle &result) { run_properties.child("w:color").attribute("w:val"))) { result.font_color = font_color; } + // [ECMA-376] 17.3.2.32: a highlight paints over a shading, so the shading + // is read only where no highlight names a colour if (const std::optional background_color = read_color_attribute( run_properties.child("w:highlight").attribute("w:val"))) { result.background_color = background_color; + } else if (const std::optional shading = read_color_attribute( + run_properties.child("w:shd").attribute("w:fill"))) { + result.background_color = shading; } } diff --git a/test/data.cmake b/test/data.cmake index 0784fa9e4..92bc76416 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -17,7 +17,7 @@ odr_test_data( odr_test_data( PATH "reference-output/odr-public" URL "https://github.com/opendocument-app/OpenDocument.test.output.git" - REVISION "a213baa014e4fb4328360602345e964f360e1445") + REVISION "1bef7f99c6e7aeea018d8df0555abf173e9e2b7e") odr_test_data( PATH "reference-output/odr-private" diff --git a/test/src/document_edit_test.cpp b/test/src/document_edit_test.cpp index b5a9811bd..3c3541aeb 100644 --- a/test/src/document_edit_test.cpp +++ b/test/src/document_edit_test.cpp @@ -6,16 +6,24 @@ #include #include +#include #include +#include #include +#include +#include +#include +#include #include #include #include #include +#include #include #include +#include #include using namespace odr; @@ -827,6 +835,312 @@ TEST(DocumentEdit, a_style_the_handle_does_not_write_refuses) { UnsupportedOperation); } +namespace { + +using Part = std::pair; + +Document package_of(const std::vector &parts) { + zip::ZipArchive archive; + for (const auto &[path, content] : parts) { + archive.insert_file(std::end(archive), RelPath(path), + std::make_shared(content)); + } + std::stringstream out; + archive.save(out); + return DecodedFile( + open_strategy::open_file(std::make_shared(out.str()), + {}, Logger::null())) + .as_document_file() + .document(); +} + +/// The smallest docx that opens, its body @p paragraphs and one paragraph +/// style `Bold`. +Document docx_of(const std::string ¶graphs) { + return package_of( + {{"_rels/.rels", + R"()" + R"()" + R"()"}, + {"word/styles.xml", + R"()" + R"()" + R"()"}, + {"word/document.xml", + R"()" + R"()" + + paragraphs + R"()"}}); +} + +/// The smallest pptx that opens: one slide, one shape, its text body holding +/// @p paragraphs. +Document pptx_of(const std::string ¶graphs) { + return package_of( + {{"ppt/presentation.xml", + R"()" + R"()"}, + {"ppt/_rels/presentation.xml.rels", + R"()" + R"()" + R"()"}, + {"ppt/slides/slide1.xml", + R"()" + R"()" + + paragraphs + R"()"}}); +} + +/// The @p ordinal -th run anywhere in @p document. +Element nth_run(const Document &document, const std::uint32_t ordinal) { + std::uint32_t seen = 0; + const auto walk = [&](this auto &&self, const Element element) -> Element { + if (element.type() == ElementType::text && seen++ == ordinal) { + return element; + } + for (const Element child : element.children()) { + if (const Element found = self(child)) { + return found; + } + } + return {}; + }; + return walk(document.root_element()); +} + +/// The part @p path of @p document saved, empty elements spelt `` +/// whichever way the writer spells them. +std::string part_of(const Document &document, const std::string &path) { + const std::shared_ptr saved = std::make_shared( + std::string(document.save_to_memory().memory_data().value())); + std::string xml = util::stream::read(*zip::ZipFile(saved) + .archive() + ->as_filesystem() + ->open(AbsPath("/" + path)) + ->stream()); + util::string::replace_all(xml, " />", "/>"); + return xml; +} + +Document reopened(const Document &document) { + return DecodedFile(open_strategy::open_file( + std::make_shared(std::string( + document.save_to_memory().memory_data().value())), + {}, Logger::null())) + .as_document_file() + .document(); +} + +const std::string docx_paragraphs = + R"(one)" + R"(bold)" + R"(ordered)"; + +const std::string pptx_paragraphs = + R"(one)" + R"(plain)"; + +} // namespace + +TEST(DocumentEdit, docx_a_mark_on_a_run_alone_in_its_w_r_writes_into_it) { + const Document document = docx_of(docx_paragraphs); + const Element run = nth_run(document, 0); + const Element holder = run.parent(); + ASSERT_EQ(holder.type(), ElementType::span); + + document.edit(ops(style_op(run, R"({"bold":true})"))); + + EXPECT_EQ(run.parent(), holder); + EXPECT_EQ(run.as_text().style().font_weight, FontWeight::bold); + EXPECT_EQ(run.as_text().style().font_style, FontStyle::italic); +} + +TEST(DocumentEdit, docx_a_mark_on_part_of_a_w_r_cuts_it) { + const Document document = docx_of(docx_paragraphs); + const Element run = nth_run(document, 0); + + document.edit(ops(R"({"op":"setText","id":)" + id_of(run) + + R"(,"text":"o"},)" + R"({"op":"insertText","after":)" + + id_of(run) + R"(,"text":"n","id":-1},)" + + R"({"op":"insertText","after":-1,"text":"e","id":-2},)" + + R"({"op":"setTextStyle","id":-1,"style":{"bold":true}})")); + + EXPECT_EQ(text_of(document.root_element()), "oneboldordered"); + EXPECT_EQ(nth_run(document, 0).as_text().style().font_weight, std::nullopt); + EXPECT_EQ(nth_run(document, 1).as_text().style().font_weight, + FontWeight::bold); + EXPECT_EQ(nth_run(document, 2).as_text().style().font_weight, std::nullopt); + // three `w:r`, each with the italic the one carried + for (const std::uint32_t ordinal : {0U, 1U, 2U}) { + const Element part = nth_run(document, ordinal); + EXPECT_EQ(part.as_text().style().font_style, FontStyle::italic); + EXPECT_NE(part.parent(), nth_run(document, (ordinal + 1) % 3).parent()); + } +} + +TEST(DocumentEdit, docx_off_is_written_over_the_paragraph_style) { + const Document document = docx_of(docx_paragraphs); + const Element run = nth_run(document, 1); + ASSERT_EQ(run.as_text().style().font_weight, FontWeight::bold); + + document.edit(ops(style_op(run, R"({"bold":false})"))); + + EXPECT_EQ(run.as_text().style().font_weight, FontWeight::normal); + EXPECT_NE(part_of(document, "word/document.xml") + .find(R"()"), + std::string::npos); +} + +TEST(DocumentEdit, docx_every_property_reaches_the_run_in_schema_order) { + const Document document = docx_of(docx_paragraphs); + const Element run = nth_run(document, 2); + + document.edit(ops(style_op( + run, + R"({"bold":true,"italic":true,"underline":true,"strikethrough":true,)" + R"("highlight":"#ffff00","color":"#ff0000","size":"14pt"})"))); + + const TextStyle style = run.as_text().style(); + EXPECT_EQ(style.font_weight, FontWeight::bold); + EXPECT_EQ(style.font_style, FontStyle::italic); + EXPECT_EQ(style.font_underline, true); + EXPECT_EQ(style.font_line_through, true); + ASSERT_TRUE(style.background_color.has_value()); + EXPECT_EQ(style.background_color->rgb(), 0xffff00U); + ASSERT_TRUE(style.font_color.has_value()); + EXPECT_EQ(style.font_color->rgb(), 0xff0000U); + ASSERT_TRUE(style.font_size.has_value()); + EXPECT_EQ(style.font_size->to_string(), "14pt"); + + // between the font and the language the file already had, in the order + // [ECMA-376] 17.3.2.28 gives + EXPECT_NE(part_of(document, "word/document.xml") + .find(R"()" + R"()" + R"()" + R"()" + R"(ordered)"), + std::string::npos); +} + +TEST(DocumentEdit, docx_a_highlight_outside_words_palette_is_a_shading) { + const Document document = docx_of(docx_paragraphs); + const Element run = nth_run(document, 1); + + document.edit(ops(style_op(run, R"({"highlight":"#123456"})"))); + + ASSERT_TRUE(run.as_text().style().background_color.has_value()); + EXPECT_EQ(run.as_text().style().background_color->rgb(), 0x123456U); + const std::string shaded = part_of(document, "word/document.xml"); + EXPECT_NE( + shaded.find(R"()"), + std::string::npos); + EXPECT_EQ(shaded.find("w:highlight"), std::string::npos); + + document.edit(ops(style_op(run, R"({"highlight":null})"))); + + EXPECT_EQ(run.as_text().style().background_color, std::nullopt); + const std::string cleared = part_of(document, "word/document.xml"); + EXPECT_NE(cleared.find(R"()"), std::string::npos); + EXPECT_EQ(cleared.find("w:shd"), std::string::npos); +} + +TEST(DocumentEdit, docx_a_mark_survives_a_save) { + const Document document = docx_of(docx_paragraphs); + document.edit(ops(style_op(nth_run(document, 0), R"({"bold":true})") + "," + + style_op(nth_run(document, 1), R"({"bold":false})"))); + + const Document saved = reopened(document); + + EXPECT_EQ(nth_run(saved, 0).as_text().style().font_weight, FontWeight::bold); + EXPECT_EQ(nth_run(saved, 0).as_text().style().font_style, FontStyle::italic); + EXPECT_EQ(nth_run(saved, 1).as_text().style().font_weight, + FontWeight::normal); +} + +TEST(DocumentEdit, pptx_a_mark_writes_the_attributes_of_a_rPr) { + const Document document = pptx_of(pptx_paragraphs); + const Element run = nth_run(document, 0); + + document.edit(ops(style_op( + run, + R"({"bold":true,"underline":true,"strikethrough":true,"size":"20pt"})"))); + + const TextStyle style = run.as_text().style(); + EXPECT_EQ(style.font_weight, FontWeight::bold); + EXPECT_EQ(style.font_style, FontStyle::italic); + EXPECT_EQ(style.font_underline, true); + EXPECT_EQ(style.font_line_through, true); + ASSERT_TRUE(style.font_size.has_value()); + EXPECT_EQ(style.font_size->to_string(), "20pt"); + EXPECT_NE(part_of(document, "ppt/slides/slide1.xml") + .find(R"(one)"), + std::string::npos); +} + +TEST(DocumentEdit, pptx_a_run_without_rPr_gets_one_ahead_of_its_text) { + const Document document = pptx_of(pptx_paragraphs); + const Element run = nth_run(document, 1); + + document.edit( + ops(style_op(run, R"({"color":"#ff0000","highlight":"#00ff00"})"))); + + ASSERT_TRUE(run.as_text().style().font_color.has_value()); + EXPECT_EQ(run.as_text().style().font_color->rgb(), 0xff0000U); + ASSERT_TRUE(run.as_text().style().background_color.has_value()); + EXPECT_EQ(run.as_text().style().background_color->rgb(), 0x00ff00U); + EXPECT_NE(part_of(document, "ppt/slides/slide1.xml") + .find(R"()" + R"()" + R"(plain)"), + std::string::npos); +} + +TEST(DocumentEdit, pptx_a_highlight_taken_away_leaves_no_element) { + const Document document = pptx_of(pptx_paragraphs); + const Element run = nth_run(document, 0); + + document.edit(ops(style_op(run, R"({"highlight":"#00ff00"})") + "," + + style_op(run, R"({"highlight":null})"))); + + EXPECT_EQ(run.as_text().style().background_color, std::nullopt); + EXPECT_EQ(part_of(document, "ppt/slides/slide1.xml").find("a:highlight"), + std::string::npos); +} + +TEST(DocumentEdit, pptx_a_mark_on_part_of_an_a_r_cuts_it) { + const Document document = pptx_of(pptx_paragraphs); + const Element run = nth_run(document, 0); + + document.edit(ops(R"({"op":"setText","id":)" + id_of(run) + + R"(,"text":"o"},)" + R"({"op":"insertText","after":)" + + id_of(run) + R"(,"text":"n","id":-1},)" + + R"({"op":"insertText","after":-1,"text":"e","id":-2},)" + + R"({"op":"setTextStyle","id":-1,"style":{"bold":true}})")); + + EXPECT_EQ(text_of(document.root_element()), "oneplain"); + EXPECT_EQ(nth_run(document, 0).as_text().style().font_weight, std::nullopt); + EXPECT_EQ(nth_run(document, 1).as_text().style().font_weight, + FontWeight::bold); + EXPECT_EQ(nth_run(document, 2).as_text().style().font_weight, std::nullopt); + for (const std::uint32_t ordinal : {0U, 1U, 2U}) { + EXPECT_EQ(nth_run(document, ordinal).as_text().style().font_style, + FontStyle::italic); + } +} + +TEST(DocumentEdit, pptx_a_mark_survives_a_save) { + const Document document = pptx_of(pptx_paragraphs); + document.edit(ops(style_op(nth_run(document, 0), R"({"bold":true})") + "," + + style_op(nth_run(document, 1), R"({"italic":true})"))); + + const Document saved = reopened(document); + + EXPECT_EQ(nth_run(saved, 0).as_text().style().font_weight, FontWeight::bold); + EXPECT_EQ(nth_run(saved, 1).as_text().style().font_style, FontStyle::italic); +} + TEST(DocumentEdit, a_paragraph_edit_refuses_another_documents_element) { const Document document = two_paragraph_text(); const Document other = two_paragraph_text(); diff --git a/test/src/internal/ooxml/ooxml_text_style_test.cpp b/test/src/internal/ooxml/ooxml_text_style_test.cpp index 738d35207..56d834e1e 100644 --- a/test/src/internal/ooxml/ooxml_text_style_test.cpp +++ b/test/src/internal/ooxml/ooxml_text_style_test.cpp @@ -623,3 +623,38 @@ TEST(ooxml_text_style, frame_offset_is_read_where_it_flows_with_the_text) { read_frame_offset(anchor.child("wp:simplePos"))); EXPECT_FALSE(read_frame_offset(anchor.child("wp:noSuchChild")).has_value()); } + +/// [ECMA-376] 17.3.2.32: a run's shading is its background where no +/// highlight paints over it. +TEST(ooxml_text_style, a_shading_is_the_background_where_no_highlight_is) { + pugi::xml_document styles; + const StyleRegistry registry = registry_of("", styles); + pugi::xml_document document; + + const TextStyle shaded = + registry + .partial_text_style(node_of( + R"()", + document)) + .text_style; + ASSERT_TRUE(shaded.background_color.has_value()); + EXPECT_EQ(shaded.background_color->rgb(), 0x123456U); + + const TextStyle highlighted = + registry + .partial_text_style( + node_of(R"()" + R"()", + document)) + .text_style; + ASSERT_TRUE(highlighted.background_color.has_value()); + EXPECT_EQ(highlighted.background_color->rgb(), 0xffff00U); + + const TextStyle automatic = + registry + .partial_text_style(node_of( + R"()", + document)) + .text_style; + EXPECT_EQ(automatic.background_color, std::nullopt); +}