Skip to content
Open
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ bytes ─▢ magic/open_strategy ─▢ DecodedFile ─▢ Document ─▢ Eleme
| `src/odr/internal/ooxml/` | OOXML (docx/pptx/xlsx); see [`ooxml/AGENTS.md`](src/odr/internal/ooxml/AGENTS.md) + per-format docs. |
| `src/odr/internal/oldms/` | **Legacy MS binary** (.doc/.ppt/.xls). |
| `src/odr/internal/pdf/` | PDF (own parser). |
| `src/odr/internal/markdown/` | Markdown (CommonMark + GFM via md4c), decoded to a text document; see [`markdown/AGENTS.md`](src/odr/internal/markdown/AGENTS.md) + [`markdown/PLAN.md`](src/odr/internal/markdown/PLAN.md). |
| `src/odr/internal/xml/` | XML, rendered as a source view; see [`xml/AGENTS.md`](src/odr/internal/xml/AGENTS.md). |
| `src/odr/internal/svg/` | SVG, detected by reading it as xml; see [`svg/AGENTS.md`](src/odr/internal/svg/AGENTS.md). |
| `src/odr/internal/{csv,json,text,svm}/` | Smaller formats. |
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ The release run heads these entries with the version and opens a fresh

## Unreleased

- `FileType::markdown` is reclassified from `FileCategory::text` to
`FileCategory::document` with `DocumentType::text`: `is_document_file()`
answers for it and `is_text_file()` no longer does, and
`file_category_by_file_type` says `document`. The row was
classification-only before, declaring no capabilities at all, so nothing
could hold a decoded markdown file to be broken.
- A markdown file opens as a text document β€” headings, paragraphs, lists,
block quotes, code blocks, emphasis, links, and GFM's tables, strikethrough
and task lists β€” and renders to html like any other document. CommonMark plus
the GitHub extensions, parsed with md4c (a new dependency). Raw html, images
and horizontal rules are not modelled yet.
Markdown has no signature, so it is never detected by content: open it as
`FileType::markdown` explicitly, or a `.md` still comes back as a text file.

## v6.10.1 - 2026-08-21

- A linked image in a docx or xlsx (`embed_images = false`) is named relative
Expand Down
8 changes: 8 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ endif ()
add_compile_options("$<$<CXX_COMPILER_ID:MSVC>:/utf-8>")

find_package(pugixml REQUIRED)
find_package(md4c REQUIRED)
find_package(miniz REQUIRED)
find_package(cryptopp REQUIRED)
find_package(nlohmann_json REQUIRED)
Expand Down Expand Up @@ -154,6 +155,12 @@ set(ODR_SOURCE_FILES
"src/odr/internal/json/json_file.cpp"
"src/odr/internal/json/json_util.cpp"

"src/odr/internal/markdown/markdown_document.cpp"
"src/odr/internal/markdown/markdown_element_registry.cpp"
"src/odr/internal/markdown/markdown_file.cpp"
"src/odr/internal/markdown/markdown_parser.cpp"
"src/odr/internal/markdown/markdown_style.cpp"

"src/odr/internal/odf/odf_crypto.cpp"
"src/odr/internal/odf/odf_document.cpp"
"src/odr/internal/odf/odf_element_registry.cpp"
Expand Down Expand Up @@ -277,6 +284,7 @@ target_include_directories(odr
target_link_libraries(odr
PRIVATE
pugixml::pugixml
md4c::md4c
miniz::miniz
cryptopp::cryptopp
nlohmann_json::nlohmann_json
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ C++ library to visualize files, especially documents, in HTML.
- [pdf](https://github.com/opendocument-app/OpenDocument.core/issues/108)
- txt
- json
- md (Markdown β€” CommonMark plus the GitHub extensions; never detected from its
bytes, so open it as `FileType::markdown` explicitly)
- [zip](https://github.com/opendocument-app/OpenDocument.core/issues/109)
- [cfb](https://github.com/opendocument-app/OpenDocument.core/issues/110) (Microsoft Compound File Binary File Format)
- ttf / otf (font specimen pages)
Expand All @@ -30,7 +32,6 @@ decoder, and opening one throws:

- rtf
- wpd (WordPerfect)
- md (Markdown)
- xlsb (Excel binary workbook β€” an OOXML package whose workbook parts are
binary rather than spreadsheetml)

Expand Down
1 change: 1 addition & 0 deletions conan.lock
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"openjpeg/2.5.4#372fbc2b4348d45ab0c0a62a8475dc2f%1760446899.685",
"nlohmann_json/3.12.0#2d634ab0ec8d9f56353e5ccef6d6612c%1744735883.94",
"miniz/3.0.2#bfbce07c6654293cce27ee24129d2df7%1743673472.805",
"md4c/0.5.2#3d7106721e458f9f799b87d4d50d02e0%1746796758.933",
"gtest/1.14.0#f8f0757a574a8dd747d16af62d6eb1b7%1743410807.169",
"cryptopp/8.9.0#7a51e0038756b21bc3a6b82d681d5906%1758206597.119",
"cpp-httplib/0.47.0#add6673ff352c26898ed2650453e706e%1784539639.401",
Expand Down
1 change: 1 addition & 0 deletions conanfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def config_options(self):
def requirements(self):
self.requires("pugixml/1.15")
self.requires("cryptopp/8.9.0")
self.requires("md4c/0.5.2")
self.requires("miniz/3.0.2")
self.requires("nlohmann_json/3.12.0")
self.requires("openjpeg/2.5.4")
Expand Down
1 change: 0 additions & 1 deletion docs/design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@

### New format support

- markdown (candidates: [md4c](https://github.com/mity/md4c), [cmark](https://github.com/commonmark/cmark))
- xml
- json
- csv
Expand Down
10 changes: 6 additions & 4 deletions src/odr/internal/file_type_table.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -452,14 +452,16 @@ constexpr std::array table{
.open = true,
.translate_html = true,
.color_scheme = true}},
// Classified for callers that route files by type; there is no decoder.
// A document rather than a text file: it decodes to a `TextRoot`. Not
// `detect_by_content` β€” markdown has no signature, and a content probe for
// it is a probe for prose, so the caller routes on the file name.
Row{FileType::markdown,
"md"sv,
markdown_extensions,
markdown_mimetypes,
FileCategory::text,
DocumentType::unknown,
{}},
FileCategory::document,
DocumentType::text,
{.open = true, .translate_html = true, .color_scheme = true}},

Row{FileType::zip,
"zip"sv,
Expand Down
134 changes: 134 additions & 0 deletions src/odr/internal/markdown/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# AGENTS.md β€” `internal/markdown`

Read the root [`AGENTS.md`](../../../../AGENTS.md) first, and
[`PLAN.md`](PLAN.md) for where this is going. This file covers what markdown
does differently, and why.

## A document, not a text file

`FileCategory::document` / `DocumentType::text`: a markdown file decodes to a
`TextRoot`, so the generic HTML renderer and every binding get it without
format-specific code. That is the whole argument for a decoder rather than a
markdown→HTML renderer next to `html/text_file.cpp` — the latter would produce
html only, with no element api and nothing for JNI/embind/pybind/ObjC.

Unlike csv, markdown is *not* an `abstract::TextFile` that also loads as a
document. `abstract::TextFile` fixes `file_category()` to `text`, and there is
no reason to read markdown as a line list once it parses as prose.

## Nothing detects it, and nothing rejects it

`detect_by_content` is **false**. Markdown has no signature, and a content probe
for it is a probe for "prose with occasional punctuation" β€” every plain text
file with a `#` comment or an `*` bullet in it. Sniffing would steal `text_file`
matches and be confidently wrong. The only way in is
`DecodedFile(file, FileType::markdown)`; callers route on the file name, which
is what they already have. **A `.md` still opens as `text_file` by default**,
and that is correct for a format that is by construction valid plain text.

There is no `NoMarkdownFile` either. Every other format's exception exists
because detection rejects; nothing rejects here β€” md4c is total, any UTF-8 byte
sequence is some markdown document. The only failure is an encoding
`internal/encoding` cannot decode, which throws `UnsupportedTextEncoding`:
`Text::content()` is UTF-8 to every binding, so legacy bytes have no document.

## md4c, not a hand-rolled parser

The opposite call from `d8c8715` (dropping a csv library to scan csv in-tree),
and deliberately so. Csv is a hundred lines of quote-aware scanning. CommonMark
has ~650 conformance cases, and the places hand-written parsers rot β€” lazy
continuation, link reference definitions, emphasis flanking rules, list-item
indent arithmetic β€” are exactly the ones that look easy for a weekend and are
then wrong forever.

md4c is a SAX parser (`enter_block` / `leave_block` / `enter_span` /
`leave_span` / `text`), which maps onto `create_element` / `append_child` with
one id stack and no intermediate tree. Dialect: `MD_DIALECT_GITHUB` plus
`MD_FLAG_COLLAPSEWHITESPACE`.

Two things the C boundary imposes:

- **An exception must not unwind through md4c's frames.** Every callback runs
through `invoke`, which parks what it throws in the `Parser` and returns
non-zero; `parse_tree` rethrows once `md_parse` has returned.
- **md4c parses bytes and assumes UTF-8**, so decoding happens before it, in
`text::TextFile::text()`, not inside it.

## Element mapping

| md4c | model |
|---|---|
| `MD_BLOCK_DOC` | `root`, default (empty) `PageLayout` β€” markdown is flow content, not paged |
| `MD_BLOCK_H` | `paragraph` + heading `TextStyle`, plus a bold `span` (see below) |
| `MD_BLOCK_P` | `paragraph` |
| `MD_BLOCK_UL` / `OL` | `list`; `MD_BLOCK_LI` β†’ `list_item` carrying its marker |
| `MD_BLOCK_QUOTE` | `group` + a left `margin` on the paragraphs inside, one step per level |
| `MD_BLOCK_CODE` | `group` of one monospace `paragraph` **per line** |
| `MD_BLOCK_HR` | β€” (nothing in the model) |
| `MD_BLOCK_HTML` | β€” (dropped) |
| `MD_BLOCK_TABLE` / `TR` / `TH` / `TD` | `table` / `table_row` / `table_cell`; `THEAD`/`TBODY` are transparent |
| `MD_SPAN_EM` / `STRONG` / `DEL` / `CODE` | `span` + the one style it means |
| `MD_SPAN_A` | `link` |
| `MD_SPAN_IMG` | β€” (transparent; the alt text flows through as text) |
| `MD_TEXT_BR` | `line_break`; `SOFTBR` is a space |
| `MD_TEXT_NULLCHAR` | U+FFFD |

### Why a heading also gets a span

`html::translate_paragraph` takes only **font family and size** from a
paragraph's text style (`translate_block_font_style`) β€” weight, slant and
decoration are expected on the spans inside. So a heading is a paragraph
carrying the whole heading style (that is where the level survives for the
element api) plus a span carrying `strong_style()` and nothing else. Only the
weight: the paragraph's size is already in `em`, and repeating it on the span
would compound. A `TH` cell is built the same way.

### Why a code block is one paragraph per line

The model has no pre-formatted block and `ParagraphStyle` has no
`white-space`, so a single paragraph would collapse the newlines. One paragraph
per line survives; `html::escape_text` turns leading and doubled spaces into
`&nbsp;`, so indentation survives with it. The info string (` ```cpp `) is
dropped β€” there is nowhere to put a language yet, which is stage 5 in
[`PLAN.md`](PLAN.md).

### Why a tight list item opens a paragraph of its own

md4c omits `MD_BLOCK_P` inside a *tight* list item, so its text arrives with no
block around it β€” and `html::translate_list_item` writes the item's marker
*into its first paragraph*, so without one the marker is silently dropped.
`open_implicit_paragraph_` opens one when inline content lands directly in a
`list_item`; the next block (or the item's `leave`) closes it again.

### Why the columns hang off their own chain

A table's children are its rows. One sibling chain cannot also carry the
columns, so `Table` keeps `first_column_id`/`last_column_id` and
`append_column` links them separately β€” the same shape `odf` uses. Getting
this wrong is not a compile error: the renderer walks `table_first_column`'s
siblings and happily writes a `<col>` for every row it runs into.

### Task lists

The model has no checkbox, so the box (`☐` / `β˜‘`) *replaces* the item's
marker rather than being invented as an element type.

## Known gaps

- **Named entities beyond the XML five (plus `&nbsp;`) stay literal.** md4c
matches anything shaped like `&name;` without knowing the list, and the conan
package ships no `entity.h`. `entity_lookup` *is* linkable from the
`md4c-html` component, but its struct is private β€” using it means declaring
that layout ourselves, where a mismatch corrupts silently rather than failing
to link. Decide that before promising CommonMark conformance; vendoring the
table is the other option.
- **Raw html is dropped**, block and inline. There is no passthrough element,
and inventing one means deciding what `Text::content()` returns for it in four
bindings β€” a real question, not a markdown question. Inline `<svg>` therefore
renders as nothing.
- **A hyperlink's href is passed through** and only `escape_attribute`d by the
renderer, exactly as an odt's is. The root `AGENTS.md` flags that
inconsistency with `html/pdf_file.cpp`'s scheme allowlist; this module is a
second consumer of the document-link policy, not a third policy.
- **Horizontal rules, images and frontmatter** are not modelled yet β€” stages 4
and 5 in [`PLAN.md`](PLAN.md).
40 changes: 36 additions & 4 deletions src/odr/internal/markdown/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
Where markdown support is going, and in what order. Written before stage 1;
keep it honest as stages land.

## Today
**Stages 1 to 3 have landed.** What the module does and why is in
[`AGENTS.md`](AGENTS.md); what follows is the original plan, with the
after-the-fact corrections marked. Stages 4 and 5 are still open.

## Today (before stage 1)

`FileType::markdown` exists (`file.hpp:73`) and has a table row with extensions
and mime types (`file_type_table.cpp:431`), declared classification-only:
Expand Down Expand Up @@ -163,6 +167,14 @@ ordered list renders as bullets; and `ElementType::group` renders as its
children with no wrapper (`:72`), so a blockquote's structure survives only in
the margins its paragraphs carry.

> Corrected while landing stage 1. The list gap is gone β€” the renderer now
> writes `div`s with `role="list"` and takes the label from
> `list_item_marker`, so an ordered list numbers itself. The group gap stands.
> Three further corrections, all in [`AGENTS.md`](AGENTS.md): a heading also
> needs a bold `span` (a paragraph's text style contributes only family and
> size); a code block becomes one paragraph *per line*, not one paragraph; and
> a tight list item needs a paragraph opened for it or its marker is dropped.

## Module layout

Mirrors `oldms/text`, which is the reference the root `AGENTS.md` points at.
Expand All @@ -180,7 +192,7 @@ Every `.cpp` goes into `ODR_SOURCE_FILES` (`CMakeLists.txt:86`).

---

## Stage 1 β€” blocks
## Stage 1 β€” blocks β€” **landed**

The skeleton, end to end, with the least that is worth rendering.

Expand All @@ -199,7 +211,10 @@ The skeleton, end to end, with the least that is worth rendering.
engines do not exceed the row, so a handful of `.md` samples go into the
test-data repo alongside this stage.

## Stage 2 β€” inlines and styles
`odr-public` already carries one `.md`, so that assertion has a file without
anything being added to the test-data repo.

## Stage 2 β€” inlines and styles β€” **landed**

- `SpanAdapter`, `LinkAdapter`; emphasis, strong, inline code, links.
- `markdown_style`: one `StyleRegistry` handing out the heading scale, the
Expand All @@ -208,7 +223,14 @@ test-data repo alongside this stage.
- entity and soft-break handling per the table; the `entity_lookup` question
above resolves here.

## Stage 3 β€” GFM
> The `entity_lookup` question resolved *against* it. The conan package does
> export the `md4c-html` component and the symbol is in the archive, but not
> `entity.h` β€” so using it means declaring md4c's private `MD_ENTITY` layout
> ourselves, which corrupts silently if it ever changes. Left out: numeric
> references, the five predefined XML entities and `&nbsp;` resolve, everything
> else stays literal. Revisit before claiming CommonMark conformance.

## Stage 3 β€” GFM β€” **landed**

`MD_DIALECT_GITHUB`: tables, strikethrough, task lists, permissive autolinks.
Tables are the substantial one β€” `TableAdapter` plus row/column/cell β€” and the
Expand All @@ -217,9 +239,19 @@ reason to do it before images: it is what people actually put in readmes.
Task list items have no checkbox in the model. Render the box as text (`☐`/`β˜‘`)
in the item's first text element rather than inventing an element type for it.

> Landed as the item's *marker* rather than its first text element β€” that is
> what a marker is, and it keeps the box out of the item's reading text.

## Stage 4 β€” images and frontmatter

Until this lands, `MD_SPAN_IMG` is transparent: the alt text flows through as
text, which is at least something to read.

- `FrameAdapter` + `ImageAdapter`, external hrefs per the decision above.
Watch the sizing: `html::translate_image` writes an `<img>` at
`width:100%;height:100%` absolutely positioned inside the frame's `div`, so a
frame with no width and height renders nothing at all. Markdown carries no
dimensions, so that path needs an answer before a frame is worth creating.
- YAML/TOML frontmatter: md4c does not know it, so strip a leading `---` fence
before parsing and expose what it holds through `FileMeta`. Parse only the
flat scalars we have somewhere to put (`title`, `author`, `date`); do not
Expand Down
Loading
Loading