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()

Reply via email to