This is an automated email from the ASF dual-hosted git repository. jimjag pushed a commit to branch odf-devel in repository https://gitbox.apache.org/repos/asf/openoffice.git
commit 26d699784ecaa6d3d5169503f74aadc7ebfe21d5 Author: Jim Jagielski <[email protected]> AuthorDate: Thu Oct 22 15:14:49 2026 -0400 Add an ODF schema validation harness under test/odf-validate Branch-only tool: keep it off trunk and AOO50X; the ODF fixes on odf-devel are separate commits so they cherry-pick without it. --- test/odf-validate/.gitignore | 6 +++ test/odf-validate/README.md | 71 +++++++++++++++++++++++++++++ test/odf-validate/compare.py | 36 +++++++++++++++ test/odf-validate/convert.py | 56 +++++++++++++++++++++++ test/odf-validate/drive.sh | 49 ++++++++++++++++++++ test/odf-validate/env.sh | 30 ++++++++++++ test/odf-validate/fetch-schemas.sh | 40 ++++++++++++++++ test/odf-validate/make-controls.py | 49 ++++++++++++++++++++ test/odf-validate/make-jobs.sh | 31 +++++++++++++ test/odf-validate/refresh-app.sh | 30 ++++++++++++ test/odf-validate/report.py | 44 ++++++++++++++++++ test/odf-validate/roundtrip.py | 57 +++++++++++++++++++++++ test/odf-validate/runpy.sh | 26 +++++++++++ test/odf-validate/selftest.py | 40 ++++++++++++++++ test/odf-validate/setup-app.sh | 36 +++++++++++++++ test/odf-validate/start.sh | 29 ++++++++++++ test/odf-validate/unohelp.py | 59 ++++++++++++++++++++++++ test/odf-validate/validate.py | 93 ++++++++++++++++++++++++++++++++++++++ 18 files changed, 782 insertions(+) diff --git a/test/odf-validate/.gitignore b/test/odf-validate/.gitignore new file mode 100644 index 0000000000..de3c5403a4 --- /dev/null +++ b/test/odf-validate/.gitignore @@ -0,0 +1,6 @@ +app/ +runs/ +schema/ +baseline/ +__pycache__/ +fixtures/control/ diff --git a/test/odf-validate/README.md b/test/odf-validate/README.md new file mode 100644 index 0000000000..fbcb6c3731 --- /dev/null +++ b/test/odf-validate/README.md @@ -0,0 +1,71 @@ +# ODF validation harness + +This harness has OpenOffice save a corpus of documents as ODF, checks every +saved package against the OASIS ODF 1.2 and ODF 1.4 RELAX NG schemas with +jing, and reports the errors grouped by kind. Comparing those reports against +a baseline shows whether an export change fixed errors or introduced new ones. + +It runs on macOS only (zsh scripts, `hdiutil`, `.app` layout). + +## Requirements + +- An AOO macOS build (`.dmg`). +- The Python that the office's pyuno links against. Check with + `otool -L <app>/Contents/program/libpyuno.dylib`, then set `ODFV_PYTHON`. +- `jing.jar` (set `ODFV_JING`) and `java`. +- `gtimeout` (GNU coreutils), used by `drive.sh`. + +Defaults for `ODFV_APP`, `ODFV_PYTHON`, `ODFV_JING` and `ODFV_SOLVER` are in +`env.sh`. + +## Setup + + ./fetch-schemas.sh # OASIS schemas, checksum-verified, into schema/ + ./setup-app.sh <path-to-AOO.dmg> # copies OpenOffice.app into app/ + python3 -I selftest.py # validator negative controls + +The schemas are not committed because they carry OASIS copyright terms. + +## Running a corpus + + ./make-jobs.sh runs/base_strict/out > runs/base_strict/jobs.tsv + ./drive.sh strict 2013 runs/base_strict/jobs.tsv runs/base_strict/done.tsv + python3 -I validate.py runs/base_strict/out runs/base_strict/val.json + python3 -I report.py runs/base_strict/val.json baseline/strict.kinds.json 12 + +The steps: +- `make-jobs.sh` collects the test documents under `main/` (doc, docx, rtf, + xls, ppt, sx*, od*). +- `drive.sh <ext|strict> <port> <jobs> <done>` saves them in ODF + 1.2 Extended (`DefaultVersion=3`) or strict ODF 1.2 (`DefaultVersion=4`). + - It runs a headless office on the given port, using a profile under `runs/`. + - It resumes where it left off, kills a hung office and continues, and + stops at the first crash. +- `validate.py` checks content, styles, meta, settings and manifest streams + against 1.2 as written and against 1.4 with `office:version` rewritten. + Embedded objects whose `content.xml` is MathML are skipped (ODF 1.2 Part 1 + ยง2.2.1 B.4). +- `report.py` groups the errors by normalized message. + +## Checking a change + + python3 -I compare.py baseline/strict.kinds.json runs/new/strict.kinds.json + +`compare.py` exits 1 if any kind is new or any kind got worse. + +To test a rebuilt module without repackaging, copy its libraries from the +solver into the app copy: + + ./refresh-app.sh libxo.dylib + +`roundtrip.py <port> <file> <checks.json>` loads a document and compares UNO +expressions with expected values; `--capture <out.json>` records them instead. + +## Notes + +- AOO has no `-convert-to`; conversion goes over UNO (`convert.py`). +- jing must run with `-i`, otherwise it rejects the ODF schemas themselves. +- A jing fatal error can hide later errors. Such lines are reported as + `UNPARSED` kinds, and `compare.py` fails on any new `UNPARSED` kind. +- `setup-app.sh` renames an existing `app/OpenOffice.app` to + `OpenOffice.app.old.<timestamp>` instead of deleting it. diff --git a/test/odf-validate/compare.py b/test/odf-validate/compare.py new file mode 100644 index 0000000000..6bfeeb33ba --- /dev/null +++ b/test/odf-validate/compare.py @@ -0,0 +1,36 @@ +#************************************************************** +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#************************************************************** + +# usage: compare.py <base.kinds.json> <new.kinds.json> +# Exit 1 if any kind is new or any kind's error or package count increased. +import sys, json +a, b = json.load(open(sys.argv[1])), json.load(open(sys.argv[2])) +bad = False +for k in sorted(set(a) | set(b)): + ea, eb = a.get(k, {}).get("errors", 0), b.get(k, {}).get("errors", 0) + pa, pb = len(a.get(k, {}).get("packages", [])), len(b.get(k, {}).get("packages", [])) + if (ea, pa) == (eb, pb): + continue + worse = k not in a or eb > ea or pb > pa + bad |= worse + print(f"{'WORSE' if worse else 'better'} errors {ea}->{eb} packages {pa}->{pb} {k[:110]}") +print("compare:", "FAIL" if bad else "OK") +sys.exit(1 if bad else 0) diff --git a/test/odf-validate/convert.py b/test/odf-validate/convert.py new file mode 100644 index 0000000000..5da6eb24e6 --- /dev/null +++ b/test/odf-validate/convert.py @@ -0,0 +1,56 @@ +#************************************************************** +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#************************************************************** + +# usage: convert.py <port> <jobs.tsv> <done.tsv> [limit] [--version-only] +# jobs.tsv: "<source>\t<output.od?>" per line; done.tsv gets START/OK/FAIL lines and is resumable +import sys, os, uno +from unohelp import connect, odf_version, desktop, load, pv, FILTERS + +port, jobs, donelog = int(sys.argv[1]), sys.argv[2], sys.argv[3] +ctx = connect(port) +print("DefaultVersion =", odf_version(ctx), flush=True) +if "--version-only" in sys.argv: + sys.exit(0) +limit = int(sys.argv[4]) if len(sys.argv) > 4 else 10**9 +desk = desktop(ctx) +done = {l.split("\t")[0] for l in open(donelog)} if os.path.exists(donelog) else set() +n = 0 +with open(donelog, "a") as log: + for line in open(jobs): + src, out = line.rstrip("\n").split("\t") + if src in done: + continue + if n >= limit: + break + n += 1 + log.write(src + "\tSTART\n"); log.flush() + try: + doc = load(desk, src) + os.makedirs(os.path.dirname(out), exist_ok=True) + doc.storeToURL(uno.systemPathToFileUrl(out), (pv("FilterName", FILTERS[out.rsplit(".", 1)[1]]),)) + doc.close(True) + log.write(src + "\tOK\n") + except Exception as e: + log.write(src + "\tFAIL " + repr(e).replace("\n", " ")[:200] + "\n") + log.flush() + if "disposed" in repr(e): + sys.exit(3) + log.flush() diff --git a/test/odf-validate/drive.sh b/test/odf-validate/drive.sh new file mode 100755 index 0000000000..02a277782a --- /dev/null +++ b/test/odf-validate/drive.sh @@ -0,0 +1,49 @@ +#!/bin/zsh +#************************************************************** +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#************************************************************** +# usage: drive.sh <ext|strict> <port> <jobs.tsv> <done.tsv> +# Converts jobs in batches of 20. Stops on the first soffice crash; kills and resumes on a hang. +source ${0:A:h}/env.sh +mode=$1; port=$2; jobs=$3; done_=$4; log=${done_:r}.log +prof=$mode +if [[ ! -e $D/runs/profile_$prof/user/registrymodifications.xcu ]]; then + mkdir -p $D/runs/profile_$prof/user + v=3; [[ $mode == strict ]] && v=4 + cat > $D/runs/profile_$prof/user/registrymodifications.xcu <<XCU +<?xml version="1.0" encoding="UTF-8"?> +<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> +<item oor:path="/org.openoffice.Office.Common/Save/ODF"><prop oor:name="DefaultVersion" oor:op="fuse"><value>$v</value></prop></item> +</oor:items> +XCU +fi +total=$(wc -l < $jobs) +(( total > 0 )) || { echo "drive.sh: no jobs in $jobs" >&2; exit 2; } +while true; do + started=$(cut -f1 $done_ 2>/dev/null | sort -u | wc -l) + (( started >= total )) && break + lsof -nP -iTCP:$port -sTCP:LISTEN >/dev/null 2>&1 || $D/start.sh $prof $port || exit 2 + gtimeout 300 $D/runpy.sh $D/convert.py $port $jobs $done_ 20 >> $log 2>&1 + rc=$?; echo "rc=$rc" >> $log + if (( rc == 124 )); then echo "HANG: $(tail -1 $done_)" >> $log; pkill -f "profile_$prof"; sleep 3 + elif (( rc != 0 )); then echo "CRASH: $(tail -2 $done_ | head -1)" >> $log; exit 1; fi +done +pkill -f "profile_$prof" +echo DONE >> $log diff --git a/test/odf-validate/env.sh b/test/odf-validate/env.sh new file mode 100644 index 0000000000..dbbabbc20d --- /dev/null +++ b/test/odf-validate/env.sh @@ -0,0 +1,30 @@ +#************************************************************** +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#************************************************************** + +# Sourced by the harness scripts. Override any ODFV_* variable in the environment. +D=${0:A:h} +: ${D:?env.sh: cannot resolve harness dir} +ROOT=${D:h:h} +APP=${ODFV_APP:-$D/app/OpenOffice.app}/Contents +# must be the Python that the office's pyuno links against (otool -L $APP/program/libpyuno.dylib) +PY=${ODFV_PYTHON:-/usr/local/Library/Frameworks/Python.framework/Versions/3.14/bin/python3.14} +JING=${ODFV_JING:-/Applications/XMLauthor/lib/jing.jar} +SOLVER=${ODFV_SOLVER:-$(print -l $ROOT/main/solver/*/unxmac*/lib(N/) | head -1)} diff --git a/test/odf-validate/fetch-schemas.sh b/test/odf-validate/fetch-schemas.sh new file mode 100755 index 0000000000..fd069f90e7 --- /dev/null +++ b/test/odf-validate/fetch-schemas.sh @@ -0,0 +1,40 @@ +#!/bin/zsh +#************************************************************** +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#************************************************************** + +# usage: fetch-schemas.sh -- download the OASIS ODF 1.2 and 1.4 RELAX NG schemas into schema/ +# They are not committed: they carry OASIS copyright terms. +source ${0:A:h}/env.sh +mkdir -p $D/schema +typeset -A SUMS +SUMS=( + https://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-schema.rng ca5d2510858c6f922b20cd27dc76d34959c1a976b9dfc6b5caeadbebfc83dbbb + https://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-manifest-schema.rng 75f972902bc785c15e51fb4e112e2f1b24e2327829eb54786cf8687ee39c7631 + https://docs.oasis-open.org/office/OpenDocument/v1.4/os/schemas/OpenDocument-v1.4-schema.rng 4034ec6be29205d5fc1ee5f42468ac6ef824287b3aba6d9289032af4fafbda7f + https://docs.oasis-open.org/office/OpenDocument/v1.4/os/schemas/OpenDocument-v1.4-manifest-schema.rng f96e1fe95f4e3609717a307c28e2fe0654c890fe70ea03eb8a53caa20b684782 +) +rc=0 +for url sum in ${(kv)SUMS}; do + f=$D/schema/${url:t} + [[ -f $f ]] || curl -sfL -o $f $url || { echo "download failed: $url"; rc=1; continue; } + [[ $(shasum -a 256 $f | cut -d' ' -f1) == $sum ]] && echo "ok ${url:t}" || { echo "CHECKSUM MISMATCH ${url:t}"; rc=1; } +done +exit $rc diff --git a/test/odf-validate/make-controls.py b/test/odf-validate/make-controls.py new file mode 100644 index 0000000000..b985e55ab8 --- /dev/null +++ b/test/odf-validate/make-controls.py @@ -0,0 +1,49 @@ +#************************************************************** +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#************************************************************** + +# usage: make-controls.py -- (re)generate the validator's negative-control packages in fixtures/control/ +import os, zipfile + +C = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures", "control") +MANI = ('<?xml version="1.0" encoding="UTF-8"?>\n<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">' + '<manifest:file-entry manifest:full-path="/" manifest:version="1.2" manifest:media-type="application/vnd.oasis.opendocument.text"/>' + '<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/></manifest:manifest>\n') +MATH = ('<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE math:math PUBLIC "-//OpenOffice.org//DTD Modified W3C MathML 1.01//EN" "math.dtd">\n' + '<math:math xmlns:math="http://www.w3.org/1998/Math/MathML"><math:semantics><math:mi>x</math:mi></math:semantics></math:math>\n') + + +def mk(name, ver, body, extra=None): + content = ('<?xml version="1.0" encoding="UTF-8"?>\n<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" ' + 'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" office:version="%s"><office:body><office:text>%s</office:text></office:body></office:document-content>\n') % (ver, body) + with zipfile.ZipFile(os.path.join(C, name + ".odt"), "w") as z: + z.writestr(zipfile.ZipInfo("mimetype"), "application/vnd.oasis.opendocument.text", compress_type=zipfile.ZIP_STORED) + z.writestr("content.xml", content, compress_type=zipfile.ZIP_DEFLATED) + z.writestr("META-INF/manifest.xml", MANI, compress_type=zipfile.ZIP_DEFLATED) + for n, t in (extra or {}).items(): + z.writestr(n, t, compress_type=zipfile.ZIP_DEFLATED) + + +os.makedirs(C, exist_ok=True) +mk("good", "1.2", "<text:p>hi</text:p>") +mk("bad", "1.2", "<text:bogus/>") +mk("wrongver", "1.1", "<text:p>hi</text:p>") +# ODF 1.2 Part 1 2.2.1 B.4: an embedded object whose content.xml is MathML +mk("mathobj", "1.2", "<text:p>hi</text:p>", {"Object 1/content.xml": MATH}) diff --git a/test/odf-validate/make-jobs.sh b/test/odf-validate/make-jobs.sh new file mode 100755 index 0000000000..6334e41c70 --- /dev/null +++ b/test/odf-validate/make-jobs.sh @@ -0,0 +1,31 @@ +#!/bin/zsh +#************************************************************** +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#************************************************************** +# usage: make-jobs.sh <outdir> > jobs.tsv -- the test-document corpus under main/, mapped to ODF outputs +source ${0:A:h}/env.sh +out=${1:?usage: make-jobs.sh <outdir>} +i=0 +find $ROOT/main \( -path '*/unxmac*' -o -path '*/solver' \) -prune -o -type f \( -name '*.odt' -o -name '*.ods' -o -name '*.odp' -o -name '*.odg' -o -name '*.sxw' -o -name '*.sxc' -o -name '*.sxi' -o -name '*.doc' -o -name '*.xls' -o -name '*.ppt' -o -name '*.rtf' -o -name '*.docx' \) -print | sort | +while read -r f; do + i=$((i+1)) + case ${f:e} in doc|docx|rtf|odt|sxw) o=odt;; xls|ods|sxc) o=ods;; ppt|odp|sxi) o=odp;; odg) o=odg;; esac + printf '%s\t%s/%04d_%s.%s\n' "$f" "$out" $i "${f:t:r}" $o +done diff --git a/test/odf-validate/refresh-app.sh b/test/odf-validate/refresh-app.sh new file mode 100755 index 0000000000..91062c769f --- /dev/null +++ b/test/odf-validate/refresh-app.sh @@ -0,0 +1,30 @@ +#!/bin/zsh +#************************************************************** +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#************************************************************** +# usage: refresh-app.sh <lib.dylib>... -- copy rebuilt libs from the solver into the app copy +source ${0:A:h}/env.sh +: ${SOLVER:?} ${APP:?} +rc=0 +for l in "$@"; do [[ -n $l && $l != */* ]] || { echo "bad lib name: $l"; exit 2; }; done +for l in "$@"; do + /bin/cp -f $SOLVER/$l $APP/program/$l && cmp -s $SOLVER/$l $APP/program/$l && echo "updated $l ($(stat -f %Sm $SOLVER/$l))" || { echo "FAILED $l"; rc=1; } +done +exit $rc diff --git a/test/odf-validate/report.py b/test/odf-validate/report.py new file mode 100644 index 0000000000..8a3fb3ccaf --- /dev/null +++ b/test/odf-validate/report.py @@ -0,0 +1,44 @@ +#************************************************************** +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#************************************************************** + +# usage: report.py <val.json> <kinds.json> [12|14] -- groups errors by normalized message +import sys, re, json, collections + + +def norm(m): + m = re.sub(r'; expected .*', '', m) + m = re.sub(r' \(with xmlns.*', '', m) + return re.sub(r'value of attribute "([^"]+)" is invalid.*', r'value of attribute "\1" is invalid', m) + + +val, out = sys.argv[1], sys.argv[2] +ver = sys.argv[3] if len(sys.argv) > 3 else "12" +d = json.load(open(val)) +kinds = collections.defaultdict(lambda: {"errors": 0, "packages": set()}) +for f, line, col, msg in d["errors"][ver]: + k = kinds[norm(msg) if f != "?" else "UNPARSED: " + msg] + k["errors"] += 1; k["packages"].add(f.split("/")[0]) +res = {k: {"errors": v["errors"], "packages": sorted(v["packages"])} for k, v in kinds.items()} +json.dump(res, open(out, "w"), indent=1, sort_keys=True) +tot = sum(v["errors"] for v in res.values()) +print(f"ODF {ver[0]}.{ver[1]}: {len(res)} kinds, {tot} errors, {len({p for v in res.values() for p in v['packages']})} packages with errors") +for k, v in sorted(res.items(), key=lambda kv: -kv[1]["errors"]): + print(f" {v['errors']:5d} {len(v['packages']):4d} {k[:120]}") diff --git a/test/odf-validate/roundtrip.py b/test/odf-validate/roundtrip.py new file mode 100644 index 0000000000..5513357bc2 --- /dev/null +++ b/test/odf-validate/roundtrip.py @@ -0,0 +1,57 @@ +#************************************************************** +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#************************************************************** + +# usage: roundtrip.py <port> <file> <checks.json> [--capture <out.json>] +# checks.json: [{"name": ..., "expr": "<python expression over doc>", "expect": <value>}] +# With --capture, evaluates each expr and writes {"name": value} instead of comparing. +import sys, json, uno +from unohelp import connect, desktop, load + + +def plain(v): + if isinstance(v, (bool, int, float, str)) or v is None: + return v + if isinstance(v, (tuple, list)): + return [plain(x) for x in v] + if isinstance(v, uno.Enum): + return v.value + if hasattr(v, "typeName"): + return {k: plain(getattr(v, k)) for k in dir(v) if not k.startswith("_") and k not in ("typeName", "value")} + return repr(v) + + +port, path, checks = int(sys.argv[1]), sys.argv[2], json.load(open(sys.argv[3])) +doc = load(desktop(connect(port)), path) +got = {} +for c in checks: + try: + got[c["name"]] = plain(eval(c["expr"], {"doc": doc, "uno": uno})) + except Exception as e: + got[c["name"]] = "ERROR " + repr(e) +doc.close(True) +if "--capture" in sys.argv: + json.dump(got, open(sys.argv[sys.argv.index("--capture") + 1], "w"), indent=1) + print(json.dumps(got, indent=1)); sys.exit(0) +fails = [(c["name"], c["expect"], got[c["name"]]) for c in checks if got[c["name"]] != c["expect"]] +for n, e, g in fails: + print(f"MISMATCH {n}: expected {e!r} got {g!r}") +print(f"roundtrip: {len(checks) - len(fails)}/{len(checks)} match") +sys.exit(1 if fails else 0) diff --git a/test/odf-validate/runpy.sh b/test/odf-validate/runpy.sh new file mode 100755 index 0000000000..004c50583c --- /dev/null +++ b/test/odf-validate/runpy.sh @@ -0,0 +1,26 @@ +#!/bin/zsh +#************************************************************** +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#************************************************************** +# usage: runpy.sh <script.py> args... -- runs a pyuno client against the app copy +source ${0:A:h}/env.sh +s=$1; shift +URE_BOOTSTRAP=vnd.sun.star.pathname:$APP/program/fundamentalrc exec $PY -I -c \ + "import sys; sys.path[:0]=['$APP/program','$D']; sys.argv=['$s']+sys.argv[1:]; exec(compile(open('$s').read(),'$s','exec'))" "$@" diff --git a/test/odf-validate/selftest.py b/test/odf-validate/selftest.py new file mode 100644 index 0000000000..2d7e860fdc --- /dev/null +++ b/test/odf-validate/selftest.py @@ -0,0 +1,40 @@ +#************************************************************** +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#************************************************************** + +# usage: selftest.py -- validator negative controls; exits 1 if any control misbehaves +import json, os, subprocess, sys +D = os.path.dirname(os.path.abspath(__file__)) +val = os.path.join(D, "runs", "control", "val.json") +os.makedirs(os.path.dirname(val), exist_ok=True) +subprocess.run([sys.executable, "-I", os.path.join(D, "make-controls.py")], check=True) +subprocess.run([sys.executable, "-I", os.path.join(D, "validate.py"), os.path.join(D, "fixtures", "control"), val], check=True, capture_output=True) +d = json.load(open(val)) +def errs(v, pkg): return [e[3] for e in d["errors"][v] if e[0].startswith(pkg + "/")] +checks = { + "good.odt clean in 1.2 and 1.4": not errs("12", "good.odt") and not errs("14", "good.odt"), + "bad.odt flags text:bogus in 1.2 and 1.4": all(any('"text:bogus"' in m for m in errs(v, "bad.odt")) for v in ("12", "14")), + "wrongver.odt flags office:version in 1.2 and 1.4": all(any('office:version' in m for m in errs(v, "wrongver.odt")) for v in ("12", "14")), + "mathobj.odt MathML object skipped": d["mathml_skipped"] == ["mathobj.odt/Object 1/content.xml"] and not errs("12", "mathobj.odt"), + "no unparsed jing output": not any(e[0] == "?" for v in d["errors"] for e in d["errors"][v]), +} +for k, ok in checks.items(): + print(("PASS " if ok else "FAIL ") + k) +sys.exit(0 if all(checks.values()) else 1) diff --git a/test/odf-validate/setup-app.sh b/test/odf-validate/setup-app.sh new file mode 100755 index 0000000000..a2f9b4b9e1 --- /dev/null +++ b/test/odf-validate/setup-app.sh @@ -0,0 +1,36 @@ +#!/bin/zsh +#************************************************************** +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#************************************************************** + +# usage: setup-app.sh <AOO macOS .dmg> -- copy OpenOffice.app out of a build's dmg into app/ +source ${0:A:h}/env.sh +dmg=${1:?usage: setup-app.sh <dmg>} +mnt=$(mktemp -d ${TMPDIR:-/tmp}/odfv-mnt.XXXXXX) +: ${mnt:?} +hdiutil attach -nobrowse -readonly -mountpoint $mnt $dmg </dev/null >/dev/null || exit 1 +if [[ -d $D/app/OpenOffice.app ]]; then + /bin/mv $D/app/OpenOffice.app $D/app/OpenOffice.app.old.$(date +%s) +fi +mkdir -p $D/app && ditto $mnt/OpenOffice.app $D/app/OpenOffice.app +rc=$? +hdiutil detach $mnt -quiet; rmdir $mnt +(( rc == 0 )) && echo "app: $D/app/OpenOffice.app ($(defaults read $D/app/OpenOffice.app/Contents/Info.plist CFBundleShortVersionString))" +exit $rc diff --git a/test/odf-validate/start.sh b/test/odf-validate/start.sh new file mode 100755 index 0000000000..1ebae14c0f --- /dev/null +++ b/test/odf-validate/start.sh @@ -0,0 +1,29 @@ +#!/bin/zsh +#************************************************************** +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#************************************************************** +# usage: start.sh <profile> <port> -- headless office on a socket, profile under runs/ +source ${0:A:h}/env.sh +mkdir -p $D/runs +nohup $APP/MacOS/soffice -headless -nofirststartwizard -norestore -nologo -nodefault \ + -env:UserInstallation=file://$D/runs/profile_$1 "-accept=socket,host=localhost,port=$2;urp;" \ + >>$D/runs/soffice_$1.log 2>&1 & +for i in {1..60}; do lsof -nP -iTCP:$2 -sTCP:LISTEN >/dev/null 2>&1 && exit 0; sleep 1; done +echo "start.sh: office on port $2 did not come up" >&2; exit 1 diff --git a/test/odf-validate/unohelp.py b/test/odf-validate/unohelp.py new file mode 100644 index 0000000000..44f5dde256 --- /dev/null +++ b/test/odf-validate/unohelp.py @@ -0,0 +1,59 @@ +#************************************************************** +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#************************************************************** + +import time, uno +from com.sun.star.beans import PropertyValue + +FILTERS = {"odt": "writer8", "ods": "calc8", "odp": "impress8", "odg": "draw8"} + + +def pv(n, v): + p = PropertyValue(); p.Name = n; p.Value = v; return p + + +def connect(port, tries=60): + local = uno.getComponentContext() + res = local.ServiceManager.createInstanceWithContext("com.sun.star.bridge.UnoUrlResolver", local) + for _ in range(tries): + try: + return res.resolve("uno:socket,host=localhost,port=%d;urp;StarOffice.ComponentContext" % port) + except Exception: + time.sleep(1) + raise SystemExit("no connection on port %d" % port) + + +def odf_version(ctx): + cp = ctx.ServiceManager.createInstanceWithContext("com.sun.star.configuration.ConfigurationProvider", ctx) + ca = cp.createInstanceWithArguments("com.sun.star.configuration.ConfigurationAccess", + (pv("nodepath", "/org.openoffice.Office.Common/Save/ODF"),)) + return ca.getByName("DefaultVersion") + + +def desktop(ctx): + return ctx.ServiceManager.createInstanceWithContext("com.sun.star.frame.Desktop", ctx) + + +def load(desk, path, readonly=True): + doc = desk.loadComponentFromURL(uno.systemPathToFileUrl(path), "_blank", 0, + (pv("Hidden", True), pv("MacroExecutionMode", 0), pv("ReadOnly", readonly), pv("UpdateDocMode", 0))) + if doc is None: + raise RuntimeError("load returned None") + return doc diff --git a/test/odf-validate/validate.py b/test/odf-validate/validate.py new file mode 100644 index 0000000000..f5e2ac8474 --- /dev/null +++ b/test/odf-validate/validate.py @@ -0,0 +1,93 @@ +#************************************************************** +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#************************************************************** + +# usage: validate.py <outdir> <val.json> +# Validates every package in <outdir> against ODF 1.2 as written and ODF 1.4 with the version rewritten. +import sys, os, re, zipfile, subprocess, json, shutil + +D = os.path.dirname(os.path.abspath(__file__)) +JING = ["java", "-jar", os.environ.get("ODFV_JING", "/Applications/XMLauthor/lib/jing.jar"), "-i"] +SCH = {"12": (D + "/schema/OpenDocument-v1.2-os-schema.rng", D + "/schema/OpenDocument-v1.2-os-manifest-schema.rng"), + "14": (D + "/schema/OpenDocument-v1.4-schema.rng", D + "/schema/OpenDocument-v1.4-manifest-schema.rng")} +STREAMS = {"content.xml", "styles.xml", "meta.xml", "settings.xml"} +# ODF 1.2 Part 1 2.2.1 B.4: a content.xml rooted at MathML is validated against MathML, not ODF +ERR = re.compile(r"^(.*?):(\d+):(\d+): (error|fatal|warning): (.*)$") + + +def root_is_mathml(text): + body = re.sub(r'<\?xml[^>]*\?>|<!DOCTYPE[^>]*>|<!--.*?-->', '', text[:4000], flags=re.S).lstrip() + return bool(re.match(r'<(?:[\w.-]+:)?math[\s>/]', body)) + + +def main(): + outdir, valjson = sys.argv[1], sys.argv[2] + work = os.path.abspath(valjson[:-5] + ".xml") + if not work.startswith(os.path.join(D, "runs") + os.sep): + sys.exit("validate.py: <val.json> must live under " + os.path.join(D, "runs")) + shutil.rmtree(work, ignore_errors=True) + files = {"12": ([], []), "14": ([], [])} + skipped, bad_zip = [], [] + pkgs = sorted(p for p in os.listdir(outdir) if not p.startswith(".")) + for pkg in pkgs: + try: + z = zipfile.ZipFile(os.path.join(outdir, pkg)) + except Exception: + bad_zip.append(pkg); continue + for name in z.namelist(): + is_mani = name == "META-INF/manifest.xml" + if name.rsplit("/", 1)[-1] not in STREAMS and not is_mani: + continue + text = z.read(name).decode("utf-8") + if not is_mani and name.endswith("content.xml") and root_is_mathml(text): + skipped.append(pkg + "/" + name); continue + for v in ("12", "14"): + t = text + if v == "14": + attr = "manifest:version" if is_mani else "office:version" + t = re.sub(attr + '="1.2"', attr + '="1.4"', t, count=1) + p = os.path.join(work, v, pkg, name) + os.makedirs(os.path.dirname(p), exist_ok=True) + open(p, "w", encoding="utf-8").write(t) + files[v][1 if is_mani else 0].append(p) + errors = {} + for v in ("12", "14"): + errs = [] + for schema, fl in ((SCH[v][0], files[v][0]), (SCH[v][1], files[v][1])): + for i in range(0, len(fl), 400): + r = subprocess.run(JING + [schema] + fl[i:i + 400], capture_output=True, text=True) + for line in (r.stdout + r.stderr).splitlines(): + m = ERR.match(line) + if m: + errs.append([os.path.relpath(m.group(1), os.path.join(work, v)), int(m.group(2)), int(m.group(3)), m.group(5)]) + elif line.strip(): + errs.append(["?", 0, 0, line.strip()]) + errors[v] = errs + json.dump({"packages": pkgs, "bad_zip": bad_zip, "mathml_skipped": skipped, + "nfiles": len(files["12"][0]) + len(files["12"][1]), "errors": errors}, open(valjson, "w")) + for v in ("12", "14"): + print(f"ODF {v[0]}.{v[1]}: {len(errors[v])} errors in {len({e[0].split('/')[0] for e in errors[v]})} of {len(pkgs)} packages") + print(f"files validated: {len(files['12'][0]) + len(files['12'][1])}, MathML skipped: {len(skipped)}, bad zips: {bad_zip}") + unparsed = [e for v in errors for e in errors[v] if e[0] == "?"] + if unparsed: + print("UNPARSED jing output:", *[e[3] for e in unparsed[:5]], sep="\n ") + + +main()
