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
2 changes: 2 additions & 0 deletions .github/cliff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ sort_commits = "oldest"
commit_parsers = [
# the stamp commit, which would otherwise land in the next release's notes
{ message = "^chore\\(release\\)", skip = true },
# the changelog heading cut, which is a version number moving and nothing else
{ message = "^docs\\(changelog\\)", skip = true },
{ message = "^feat", group = "<!-- 0 -->Features" },
{ message = "^fix", group = "<!-- 1 -->Fixes" },
{ message = "^perf", group = "<!-- 2 -->Performance" },
Expand Down
11 changes: 10 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: release

# Merge main into `releases`, push, publish the draft this leaves behind.
# Cut the `## Unreleased` heading in CHANGELOG.md to the version being released,
# merge main into `releases`, push, publish the draft this leaves behind.
#
# To patch an older line, branch off the *tag* β€” `git branch release/v6.1.X
# v6.1.0` β€” not off `releases`: the version is derived against the nearest
Expand Down Expand Up @@ -61,6 +62,14 @@ jobs:
echo "cutting $VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

# Here rather than beside `notes`, so release copy nobody wrote costs
# seconds instead of a version. Dry runs included β€” a dry run is the
# rehearsal for the release body.
- name: check the changelog names this version
env:
VERSION: ${{ steps.derive.outputs.version }}
run: scripts/release.py changelog --version "$VERSION" > /dev/null

# SwiftPM resolves `Package.swift` at the tag, and its binary target names a
# sha256 of an archive that does not exist until it is built β€” so the archive
# is built here, against the version derived above, and its checksum comes
Expand Down
33 changes: 26 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,26 +103,44 @@ cmake --build cmake-build-relwithdebinfo --target translate # CLI: file β†’ HTM

## Releasing

Merge main into `releases`, push, publish the draft that appears β€”
`.github/workflows/release.yml` and `scripts/release.py`.
Cut the changelog heading, merge main into `releases`, push, publish the draft
that appears β€” `.github/workflows/release.yml` and `scripts/release.py`.

- **`main` carries no version.** No file is bumped, no changelog committed; a
build records `GIT_HEAD_SHA1` and a dirty flag and nothing else. The version
- **`main` carries no version in anything the build reads.** No file is bumped;
a build records `GIT_HEAD_SHA1` and a dirty flag and nothing else. The version
is derived from the commit subjects (`git cliff --bumped-version`), so writing
them properly is load-bearing.
- **`CHANGELOG.md` is the exception, and only as prose.** A change a consumer
would notice gets an entry under `## Unreleased` in the pull request that
makes it. Cut that heading to the version β€” `docs(changelog): cut vX.Y.Z` β€” as
the last commit on main before the merge, so both branches carry the identical
cut and the merge stays clean. The run refuses a version with no section, and
puts that section above the generated commit list in the release body.
- **Which version that is cannot be derived from main.** Since the release
train, tags sit on `chore(release)` commits that only `releases` carries, so
main's nearest reachable tag is v6.1.0 and drifts further every release β€”
`scripts/release.py version` only answers correctly where it runs, on
`releases` after the merge. Take the last release (`gh release list -L 1`) and
bump the minor if a `feat:` landed since, the patch otherwise. Guess wrong and
the changelog check refuses in the first job, before anything is built; re-cut
the heading, or dispatch with `--version`.
- **`releases` is the mainline train**; its first-parent history is the release
history. To patch an older line, branch off the *tag* (`git branch
release/v6.1.X v6.1.0`) β€” the version is derived against the nearest
*reachable* tag. That is also the trap: a `feat:` there bumps the minor to a
number the mainline may already have shipped, so `release.py version` refuses
a version that is already tagged. Pass `--version` when you mean it.
a version that is already tagged. Pass `--version` when you mean it. Such a
branch also keeps its own changelog: it covers what that branch contains, so
it neither knows about later mainline versions nor sends its sections back to
main β€” a cherry-pick brings the entry with it, and the mainline entry for the
same fix can mention the backport.
- **Release branches run `release.yml` only**; every other workflow carries
`branches-ignore: ['releases', 'release/**']`.
- **The release is drafted, and a human publishes it.** GitHub creates the tag
only then, which is what lets it point at a commit made during the run. It
also has to be a human: a release created by `GITHUB_TOKEN` raises no
`release: published`, and that event starts conan, maven and android.
- **`release.yml` is the only place that writes a version anywhere**, and
- **`release.yml` is the only place that writes a version into the build**, and
`release.py stamp` commits it as `chore(release): vX.Y.Z`. Today that is
`Package.swift`: SwiftPM resolves it at the tag, and its binary target names
the sha256 of an archive that does not exist until the release builds it. Off
Expand Down Expand Up @@ -177,7 +195,8 @@ Merge main into `releases`, push, publish the draft that appears β€”
- **Doc-comment markers**: `///` for functions/classes/structs/enums; trailing
`///<` for the short note on the same line (enumerator/member). Keep terse.
- **Pull requests**: put the `πŸ€– Generated with [Claude Code](https://claude.com/claude-code)`
line **at the top** of the PR body.
line **at the top** of the PR body. If the change is one a consumer would
notice, add its `CHANGELOG.md` entry in the same PR β€” see *Releasing*.

## Adding / extending a document format

Expand Down
140 changes: 140 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Changelog

What changed in the engine, for whoever builds against it β€” the apps, the
bindings, and the packages on conan, maven, PyPI, npm and SwiftPM. Each
[release](https://github.com/opendocument-app/OpenDocument.core/releases) puts
its section on top of the generated per-pull-request list. Back to v5.3.0;
before that the releases are all there is.

An entry goes under `Unreleased` in the pull request that makes the change, and
only for what a consumer notices: API, formats, rendering, behaviour, packaging.
Refactoring, tests and CI are in the generated list already. A breaking change
says **Breaking** first.

Cutting the heading to the version is the last commit on `main` before the merge
into `releases`, and the run refuses a version with no section here. A
maintenance branch keeps its own history β€” sections written there are not merged
back.

## Unreleased

- csv opens as a spreadsheet, its dialect probed unless the caller gives one,
and a value that only looks numeric left as text.
- Text encodings are named and decoded, so a document that is not UTF-8 comes
out as text rather than mojibake.
- An archive's entries are served as files instead of one page of base64.
- A docx renders on the page it was written for, and a numbered list carries its
markers as text a copy keeps.
- Sheets render in a quieter grid, under a ruler that stays put while scrolling.

## v6.3.0 - 2026-08-08

- The engine runs in the browser. A WebAssembly build published as
`@opendocument/odr-core` renders documents with no server.
- A file can be opened from memory β€” `File::from_memory` β€” and every entry point
that took a path now also takes a `File`.
- `translate` no longer needs a cache path. The overloads taking one remain and
ignore it; nothing on the render path writes to disk.
- svg, ico, jxl, jp2, psd, wmf and emf are detected and translated as images.
- Embedded PDF fonts no longer render as tofu or as the wrong glyphs.
- Two translates can run at once. The unit registry and the logger clock were
not thread-safe, and the HTTP server serves from a thread pool.
- argon2id is implemented in-tree, which drops the `conan-odr-index` remote:
every dependency now comes from ConanCenter.

## v6.2.0 - 2026-08-02

- Objective-C bindings and a Swift package, with the `OdrCoreObjC.xcframework`
attached to each release.
- The renderer's css and js are written into the html, so a consumer no longer
ships `odr.js` or points the library at a data directory.
- Audio and video play in the browser, and the media formats the engine could
only name are now opened.
- `mimetype` is answered without libmagic, and the libmagic option is
deprecated.
- The html viewport config is bound for Java and Python.
- A release reports where it landed, so one that reached only some of the
package registries is visible instead of silent.

## v6.1.0 - 2026-07-30

- The supported-format tables are public, with a per-file-type capability query,
so a caller can ask what the engine does with a type before holding a file.
- The Java library and a new Android AAR are published to Maven Central, the
AAR tested on a device.
- `HttpServer::stop()` and destruction wait for `listen()` to return.
- A JNI wrapper stays reachable while its handle is in a native call.

## v6.0.1 - 2026-07-27

- The logger thread attaches to the JVM on Android.

## v6.0.0 - 2026-07-27

**Breaking.** A cleanup of the public API, source-incompatible throughout.

- The deprecated API and the pdf2htmlEX / wvWare backends are gone, and with
them `DecoderEngine` and the whole decoder-selection dimension β€” with the
external backends dropped there was one engine left to select.
- `FileMeta` absorbs `DocumentMeta`, `Logger` is a value type with a public
`ILogger` sink, and `HttpServer::listen` splits into `bind` and `listen`.
- Shape geometry is typed: `Frame`, `Rect`, `Line`, `Circle` and `CustomShape`
return `Measure`, not `std::string`. `Measure::to_string` keeps 7 significant
digits instead of 4, so drawing coordinates stop being rounded away.
- Every exception derives from `odr::Exception`; `Color` uses `from_rgb` /
`from_argb`; `const char *` is gone from the public headers in favour of
`std::string_view`.
- Two long-standing defects: `ElementIterator::operator++(int)` could not
advance, and elements compared equal across unrelated documents.
- The python wheel ships the libmagic database inside it.

## v5.7.1 - 2026-07-26

- The Java API loads on Android API 26 again.
- Conan consumers can turn the cli off.

## v5.7.0 - 2026-07-26

- Python bindings (`pyodr`) live here rather than in `OpenDocument.py`.
- JNI bindings with an `app.opendocument.core` Java API, published to GitHub
Packages.

## v5.6.0 - 2026-07-25

- Initial zoom is configurable through `HtmlViewportMode`, and fixed-size
content β€” PDF and images β€” opens fit-to-width on mobile.

## v5.5.0 - 2026-07-25

- Legacy Microsoft formats gain formatting: character formatting in doc and ppt,
cell fonts and fills in xls, and pictures, slide size and slide names in ppt.
- OOXML gains pptx slide size and tables, xlsx merged cells and value types, and
docx table merges; ODF gains sub/superscript, percent line-height and
first-line indent.
- PDF exposes per-page html views, and a page range limits any document.

## v5.4.1 - 2026-07-11

- **Relicensed from GPL-3.0 to MPL-2.0.**
- The HTTP server is optional (`ODR_WITH_HTTP_SERVER`), and asset bundling
defaults to off.

## v5.4.0 - 2026-07-05

- **PDF is rendered by this engine.** Text is placed by its baseline, embedded
TrueType, CFF, Type1 and Type3 fonts are used and missing ones substituted
against standard-14 metrics, and images, paths, clipping, shadings, tiling
patterns, transparency and blend modes are drawn. Links become `<a>` overlays.
- Encrypted PDFs open with the standard security handler, damaged
cross-reference tables are recovered, and xref streams, object streams and
hybrid files are read.
- Text extraction handles ToUnicode CMaps, composite fonts and the legacy CJK
CMaps, so older documents no longer come out garbled.
- Initial ppt and xls support, read in-tree.

## v5.3.0 - 2026-06-06

Mostly build and CI.

- Plain-text output honours `HtmlConfig::editable`.
- CMake options are prefixed `ODR_`.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ As an alternative to the Conan remote you can also export the package locally vi

## Version

Versions and history are tracked on [GitHub](https://github.com/opendocument-app/OpenDocument.core).
What changed per version is in [`CHANGELOG.md`](CHANGELOG.md), and in full in the
[releases](https://github.com/opendocument-app/OpenDocument.core/releases).

## Testing

Expand Down
61 changes: 58 additions & 3 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
Driven by `.github/workflows/release.yml`:

version what the commits since the last reachable tag say comes next
notes the release body, from the same commits
changelog the CHANGELOG.md section for a version, and nothing if there is none
notes the release body: that section above the generated commit list
stamp commit whatever the workflow wrote into the tree, if anything
publish create or update the draft release, targeting HEAD, with assets

Expand All @@ -19,6 +20,7 @@
Runnable by hand; `--dry-run` mutates nothing:

scripts/release.py version
scripts/release.py changelog --version v6.2.0
scripts/release.py notes --version v6.2.0 --output /tmp/notes.md
scripts/release.py publish --version v6.2.0 --notes /tmp/notes.md --dry-run
"""
Expand All @@ -27,12 +29,17 @@

import argparse
import os
import re
import subprocess
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
CLIFF_CONFIG = REPO_ROOT / ".github" / "cliff.toml"
CHANGELOG = REPO_ROOT / "CHANGELOG.md"

# `## v6.3.0 - 2026-08-08`, or `## Unreleased`; the date is decoration
VERSION_HEADING = re.compile(r"^## +(\S+)")

# skipped by `cliff.toml`, so it stays out of the next release's notes
STAMP_SUBJECT = "chore(release): {version}"
Expand Down Expand Up @@ -96,9 +103,52 @@ def command_version(arguments: argparse.Namespace) -> None:
print(version)


def changelog_section(version: str) -> str:
"""The body under `## <version>` in CHANGELOG.md.

Read before anything is built, so release copy nobody wrote costs seconds
rather than a version. Being read is what keeps the changelog from rotting.
"""
wanted = version.strip().removeprefix("v")

found = False
body: list[str] = []
for line in CHANGELOG.read_text().splitlines():
heading = VERSION_HEADING.match(line)
if heading:
if found:
break
found = heading.group(1).removeprefix("v") == wanted
continue
if found:
body.append(line)

if not found:
raise SystemExit(
f"CHANGELOG.md has no section for {version}. Cut the `## Unreleased` "
f"heading to `## {version} - <date>` on main before merging into "
f"`releases` β€” that copy is the top of the release body."
)

text = "\n".join(body).strip("\n")
if not text:
raise SystemExit(
f"the `## {version}` section of CHANGELOG.md is empty. A release "
f"with nothing worth telling a consumer should say so, not nothing."
)
return text


def command_changelog(arguments: argparse.Namespace) -> None:
print(changelog_section(arguments.version))


def command_notes(arguments: argparse.Namespace) -> None:
cliff("--tag", arguments.version, "--latest", "--unreleased",
"-o", str(arguments.output))
"""The hand-written section, then the commits it summarises."""
summary = changelog_section(arguments.version)
generated = cliff("--tag", arguments.version, "--latest", "--unreleased",
capture=True)
arguments.output.write_text(f"{summary}\n\n---\n\n{generated}\n")


def command_stamp(arguments: argparse.Namespace) -> None:
Expand Down Expand Up @@ -174,6 +224,11 @@ def main() -> None:
version.add_argument("--version", help="override what the commits say")
version.set_defaults(function=command_version)

changelog = subparsers.add_parser("changelog",
help="print the CHANGELOG.md section")
changelog.add_argument("--version", required=True)
changelog.set_defaults(function=command_changelog)

notes = subparsers.add_parser("notes", help="write the release body")
notes.add_argument("--version", required=True)
notes.add_argument("--output", type=Path, required=True)
Expand Down
Loading