Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ The release run heads these entries with the version and opens a fresh
- New `HtmlViewportMode::fit_width_by_view`: the view measures the fit and keeps
it current, so a rotation refits instead of holding what it opened at. For a
host whose web view does not fit a top-level document itself.
- A `.pptx` whose package relates a part that is not xml — a Google Slides
export relates a protobuf — opens instead of throwing `NoXmlFile`. Only the
slides `p:sldIdLst` names are read.
- A `.pptx` line break (`a:br`) renders as one instead of joining the runs
around it, and a run's font, a paragraph's alignment and its left and right
margins arrive: they were read from the wordprocessingml attributes, which a
pptx never carries.

## v6.10.1 - 2026-08-21

Expand Down
20 changes: 20 additions & 0 deletions src/odr/internal/ooxml/ooxml_util.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,26 @@ ooxml::read_text_align_attribute(const pugi::xml_attribute attribute) {
return {};
}

/// [ECMA-376] 20.1.10.59 ST_TextAlignType — drawingml spells the same values
/// differently than wordprocessingml does.
std::optional<TextAlign>
ooxml::read_drawing_text_align_attribute(const pugi::xml_attribute attribute) {
const char *val = attribute.value();
if (std::strcmp("l", val) == 0) {
return TextAlign::left;
}
if (std::strcmp("r", val) == 0) {
return TextAlign::right;
}
if (std::strcmp("ctr", val) == 0) {
return TextAlign::center;
}
if (std::strcmp("just", val) == 0) {
return TextAlign::justify;
}
return {};
}

std::optional<VerticalAlign>
ooxml::read_vertical_align_attribute(const pugi::xml_attribute attribute) {
const char *val = attribute.value();
Expand Down
1 change: 1 addition & 0 deletions src/odr/internal/ooxml/ooxml_util.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ std::optional<FontWeight> read_font_weight_attribute(pugi::xml_node);
std::optional<FontStyle> read_font_style_attribute(pugi::xml_attribute);
std::optional<FontStyle> read_font_style_attribute(pugi::xml_node);
std::optional<TextAlign> read_text_align_attribute(pugi::xml_attribute);
std::optional<TextAlign> read_drawing_text_align_attribute(pugi::xml_attribute);
std::optional<VerticalAlign> read_vertical_align_attribute(pugi::xml_attribute);
std::optional<std::string> read_border_node(pugi::xml_node);

Expand Down
37 changes: 24 additions & 13 deletions src/odr/internal/ooxml/presentation/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,32 @@ runs, tables, inline text styling.

## Design decisions

**Parsing follows the slide-id list.** The `Document` ctor loads every
relationship target of `presentation.xml` into an `rId → xml` map (masters,
layouts, theme included — only slides are actually walked). Slide **order = the
document order of `p:sldId` in `p:sldIdLst`** (not filename/rId order); each
slide's `r:id` looks up its part, and parsing descends `p:cSld/p:spTree`.
Dispatch table: `p:sp`→**frame** (shapes are frames), `p:graphicFrame`→frame
(descends `a:graphic/a:graphicData`), `p:txBody`→group, `a:p`→paragraph,
`a:r`→span, `a:t`→text, `a:tbl`→table (columns from `a:tblGrid/a:gridCol` via
`append_column`, rows/cells from `a:tr`/`a:tc`; spans from
`gridSpan`/`rowSpan`, covered cells from `hMerge`/`vMerge`).
**Parsing follows the slide-id list.** Slide **order = the document order of
`p:sldId` in `p:sldIdLst`** (not filename/rId order), and that list is also what
the `Document` ctor loads: each `p:sldId`'s `r:id` resolves through
`presentation.xml`'s relationships into the `rId → xml` map, and nothing else
does. Loading *every* relationship target instead is what broke a Google Slides
export — it relates a protobuf blob (`ppt/metadata`) to the presentation, and
parsing that as xml threw `NoXmlFile` before a single slide was read. A package
may relate anything at all; only slides are xml we can use. Parsing then
descends `p:cSld/p:spTree`. Dispatch table: `p:sp`→**frame** (shapes are
frames), `p:graphicFrame`→frame (descends `a:graphic/a:graphicData`),
`p:txBody`→group, `a:p`→paragraph, `a:r`→span, `a:t`→text, `a:br`→line break,
`a:tbl`→table (columns from `a:tblGrid/a:gridCol` via `append_column`,
rows/cells from `a:tr`/`a:tc`; spans from `gridSpan`/`rowSpan`, covered cells
from `hMerge`/`vMerge`).

**Styles are resolved inline — there is no `StyleRegistry`.** Free functions in
the document read `a:rPr` / `a:pPr` attributes directly (font, size in
hundredth-points, bold/italic/underline/strike/shadow/colour/highlight; align,
`a:ind` margins in twips). The element-parent cascade
the document read `a:rPr` / `a:pPr` directly: font from `a:latin/@typeface`,
size in hundredth-points, bold/italic/underline/strike/shadow; align from
`@algn` ([ECMA-376] 20.1.10.59 `ST_TextAlignType`, which spells the values
differently than wordprocessingml does), `@marL`/`@marR` margins in EMUs. **Read
them where drawingml puts them, not where wordprocessingml does** — these were
`rFonts@ascii`, `@jc` and `a:ind` for a while, none of which a pptx ever
carries, so the properties simply never arrived. Run **colour is still unread**
on purpose: it lives in `a:solidFill`, but nothing paints a shape or slide
background yet, so honouring a white run would put white text on white — it
lands with background fill, not before. The element-parent cascade
(`get_intermediate_style` → `.override()`) is the same shape as ODF/docx but
computed on-demand from the XML with no cached or master/default-style
contribution.
Expand Down
17 changes: 9 additions & 8 deletions src/odr/internal/ooxml/presentation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ Reader for presentation documents (`.pptx`).

This implementation relies on [OOXML](../README.md).

The presentation is parsed from `ppt/presentation.xml`, with each slide pulled in
via relationships and its shape tree (`p:cSld` / `p:spTree`) walked for content
(see `ooxml_presentation_parser.cpp`). Text and paragraph styles are resolved
inline from the run / paragraph properties (see
The presentation is parsed from `ppt/presentation.xml`, with each slide named by
`p:sldIdLst` pulled in via relationships and its shape tree (`p:cSld` /
`p:spTree`) walked for content (see `ooxml_presentation_parser.cpp`). Text and
paragraph styles are resolved inline from the run / paragraph properties (see
`ooxml_presentation_document.cpp`).

## Features
Expand Down Expand Up @@ -38,16 +38,17 @@ Roughly ordered by importance.
### Styles

- [x] font
- [x] family (`rFonts`)
- [x] family (`a:latin`)
- [x] size
- [x] italic, bold
- [x] underline, strike through
- [x] color, background (highlight)
- [ ] color, background (highlight) — waits on background fill, see
[`AGENTS.md`](AGENTS.md)
- [x] shadow
- [ ] superscript, subscript
- [x] paragraph
- [x] alignment
- [x] indentation / left & right margins
- [x] alignment (`a:pPr/@algn`)
- [x] indentation / left & right margins (`@marL` / `@marR`)
- [x] tables (column widths, row heights; no `a:tcPr` cell styles)
- [x] page layout (slide size)
- [ ] graphic / drawing styles
Expand Down
45 changes: 19 additions & 26 deletions src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,16 @@ Document::Document(std::shared_ptr<abstract::ReadableFilesystem> files)
DocumentType::presentation, std::move(files)) {
m_document_xml = util::xml::parse(*m_files, AbsPath("/ppt/presentation.xml"));

for (const auto &[id, target] :
parse_relationships(*m_files, AbsPath("/ppt/presentation.xml"))) {
m_slides_xml[id] =
util::xml::parse(*m_files, AbsPath("/ppt").join(RelPath(target)));
// Only the parts the slide-id list names: a package may relate anything at
// all to the presentation, and a Google Slides export relates a protobuf.
const Relations relations =
parse_relationships(*m_files, AbsPath("/ppt/presentation.xml"));
for (const pugi::xml_node slide_id : m_document_xml.document_element()
.child("p:sldIdLst")
.children("p:sldId")) {
const std::string id = slide_id.attribute("r:id").value();
m_slides_xml[id] = util::xml::parse(
*m_files, AbsPath("/ppt").join(RelPath(relations.at(id))));
}

// ECMA-376 default slide size when p:sldSz is absent.
Expand Down Expand Up @@ -81,7 +87,7 @@ void resolve_text_style_(const pugi::xml_node node, TextStyle &result) {
const pugi::xml_node run_properties = node.child("a:rPr");

if (const pugi::xml_attribute font_name =
run_properties.child("rFonts").attribute("ascii")) {
run_properties.child("a:latin").attribute("typeface")) {
result.font_name = font_name.value();
}
if (const std::optional<Measure> font_size =
Expand All @@ -108,38 +114,25 @@ void resolve_text_style_(const pugi::xml_node node, TextStyle &result) {
read_shadow_attribute(run_properties.attribute("shadow"))) {
result.font_shadow = font_shadow;
}
if (const std::optional<Color> font_color =
read_color_attribute(run_properties.attribute("color"))) {
result.font_color = font_color;
}
if (const std::optional<Color> background_color =
read_color_attribute(run_properties.attribute("highlight"))) {
result.background_color = background_color;
}
// `a:solidFill` colour is left unread on purpose until backgrounds are
// painted — see AGENTS.md.
}

void resolve_paragraph_style_(const pugi::xml_node node,
ParagraphStyle &result) {
const pugi::xml_node paragraph_properties = node.child("a:pPr");

if (const std::optional<TextAlign> text_align =
read_text_align_attribute(paragraph_properties.attribute("jc"))) {
read_drawing_text_align_attribute(
paragraph_properties.attribute("algn"))) {
result.text_align = text_align;
}
if (const std::optional<Measure> margin_left = read_twips_attribute(
paragraph_properties.child("ind").attribute("left"))) {
result.margin.left = margin_left;
}
if (const std::optional<Measure> margin_left = read_twips_attribute(
paragraph_properties.child("ind").attribute("start"))) {
if (const std::optional<Measure> margin_left =
read_emus_attribute(paragraph_properties.attribute("marL"))) {
result.margin.left = margin_left;
}
if (const std::optional<Measure> margin_right = read_twips_attribute(
paragraph_properties.child("ind").attribute("right"))) {
result.margin.right = margin_right;
}
if (const std::optional<Measure> margin_right = read_twips_attribute(
paragraph_properties.child("ind").attribute("end"))) {
if (const std::optional<Measure> margin_right =
read_emus_attribute(paragraph_properties.attribute("marR"))) {
result.margin.right = margin_right;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ parse_any_element_tree(ElementRegistry &registry, const ParseContext &context,
{"a:txBody", create_default_tree_parser(ElementType::group)},
{"a:t", parse_text_element},
{"a:tab", parse_text_element},
{"a:br", create_default_tree_parser(ElementType::line_break)},
{"a:p", create_default_tree_parser(ElementType::paragraph)},
{"a:r", create_default_tree_parser(ElementType::span)},
{"a:tbl", parse_table_element},
Expand Down
6 changes: 3 additions & 3 deletions test/data.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ odr_test_data(
odr_test_data(
PATH "input/odr-private"
URL "https://github.com/opendocument-app/OpenDocument.test-private.git"
REVISION "112cf18f0ef246dfa31ac21f95f5167d0aaa2bcc")
REVISION "b71cec155018311c130b98c07ddee5e853db9e26")

odr_test_data(
PATH "reference-output/odr-public"
URL "https://github.com/opendocument-app/OpenDocument.test.output.git"
REVISION "7dd2d881f7d832d119ab1369d8725c1bbdb9819e")
REVISION "9b4bfd1ecc57c2e0abd6e006be360d1adb34db24")

odr_test_data(
PATH "reference-output/odr-private"
URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git"
REVISION "a1150488e8e2b1ab0d6351373946a6be36858afb")
REVISION "5fc72ac725c2ab2c0be79bdc119203d7cda429e4")
Loading