This is an automated email from the ASF dual-hosted git repository. jimjag pushed a commit to branch trunk in repository https://gitbox.apache.org/repos/asf/openoffice.git
commit 9fb741fc0a32772b6a4c20c7b885bb4cc21d52d0 Author: Jim Jagielski <[email protected]> AuthorDate: Tue Nov 3 06:14:09 2026 -0500 test/odf-validate: ODF 1.4 fixtures for handles, charts, forms and consumer packages; compare the 1.4 modes against the 1.4 schema --- test/odf-validate/.gitignore | 1 + test/odf-validate/calcval-cases14.tsv | 5 +- test/odf-validate/consumer_smoke.py | 82 +++++++++ test/odf-validate/fixtures/t14/chart.py | 74 ++++++++ test/odf-validate/fixtures/t14/forms.py | 73 ++++++++ test/odf-validate/fixtures/t14/handles.py | 74 ++++++++ test/odf-validate/fixtures/t14/make-consumer.py | 215 ++++++++++++++++++++++++ test/odf-validate/fixtures/t14/resave.py | 44 +++++ test/odf-validate/fixtures/t14/run-in-mode.sh | 41 +++++ test/odf-validate/gate.py | 14 +- 10 files changed, 616 insertions(+), 7 deletions(-) diff --git a/test/odf-validate/.gitignore b/test/odf-validate/.gitignore index 56e213084b..b03b2f819a 100644 --- a/test/odf-validate/.gitignore +++ b/test/odf-validate/.gitignore @@ -7,3 +7,4 @@ fixtures/control/ fixtures/control-pkg/ fixtures/control-doctype/ tools/ +fixtures/t14/consumer/ diff --git a/test/odf-validate/calcval-cases14.tsv b/test/odf-validate/calcval-cases14.tsv index 7d9e1c2622..ddee2473a2 100644 --- a/test/odf-validate/calcval-cases14.tsv +++ b/test/odf-validate/calcval-cases14.tsv @@ -31,7 +31,7 @@ CONVERT.atm =CONVERT(1;"atm";"Pa") cell 101325.1 6.16.18 Table 24: atm is the St CONVERT.SI_at =CONVERT(1;"SI_at";"Pa") cell 98066.5 6.16.18 Table 24: SI_at is the Technical atmosphere, 9.8066510E+4 Pa CONVERT.at =CONVERT(1;"at";"Pa") cell - 6.16.18 Table 24: at is deprecated, recorded only NPER.omit =NPER(0.01;-100;1000)-NPER(0.01;-100;1000;0;0) cell 0 6.12.29 Fv and PayType default to 0 -NPER.empty =NPER(0.01;-100;1000;;)-NPER(0.01;-100;1000;0;0) cell 0 6.12.29 empty Fv and PayType parameters take the defaults +NPER.empty =NPER(0.01;-100;1000;;)-NPER(0.01;-100;1000;0;0) cell 0 6.12.29 empty Fv and PayType take the defaults; known deviation: the formula compiler rejects an empty last parameter before ')' for every function except IF (Err:511) NPER.paytype1 =NPER(0.01;-100;1000;0;1)-NPER(0.01;-100;1000;0;0) cell - 6.12.29 PayType 1 differs; recorded only INDEX.col.row =INDEX(D1:D5;2) cell 20 6.14.6 column vector, Row only: the entire row of the area INDEX.col.rowcol =INDEX(D1:D5;2;1) cell 20 6.14.6 column vector, Row and Column @@ -65,3 +65,6 @@ Y1500.leap =DATE(1500;3;1)-DATE(1500;2;28) cell - 7.4 before 1583 the system is Y1582.switch =DATE(1582;10;15)-DATE(1582;10;4) cell - 7.4 proleptic Gregorian gives 11, a Julian-to-Gregorian switch gives 1 CONVERT.g.kg =CONVERT(1;"g";"kg") cell 0.001 control: the add-in rewrite reaches a known unit pair CONVERT.bogus =CONVERT(1;"nosuchunit";"Pa") cell #ERR control: an unknown unit is an error +EASTER.mid =EASTERSUNDAY(1000) cell #ERR 6.10.8 constraint 1583 <= Year (100 to 1582) +EASTER.neg =EASTERSUNDAY(-1) cell #ERR 6.10.8 constraint 1583 <= Year (negative years used to wrap) +EASTER.wrap =EASTERSUNDAY(67536) cell #ERR 6.10.8 constraint Year <= 9956 (67536 used to wrap to 2000) diff --git a/test/odf-validate/consumer_smoke.py b/test/odf-validate/consumer_smoke.py new file mode 100644 index 0000000000..da50cf9c3c --- /dev/null +++ b/test/odf-validate/consumer_smoke.py @@ -0,0 +1,82 @@ +#************************************************************** +# +# 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: consumer_smoke.py <tag> [--port N] [--stall SECONDS] [--dir DIR] +# Import smoke test for the ODF 1.4 additions: reloads every package of fixtures/t14/consumer (made by make-consumer.py) +# with the office of the app copy, dumps a fixed property set of each (dump7.py with the gate's stall watchdog) and compares +# every package with its '-base' twin (the same document without the construct, diff7.py). Reports per package: loaded or +# not (an error marker in the dump), and the number of differing dump keys against the twin. A package without a twin +# is only checked for loading. Results in runs/consumer-<tag>/ (summary in summary.json). +import argparse, importlib.util, json, os, shutil, subprocess, sys + +D = os.path.dirname(os.path.abspath(__file__)) +spec = importlib.util.spec_from_file_location("gate", D + "/gate.py") +gate = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("tag") + ap.add_argument("--port", type=int, default=2950) + ap.add_argument("--stall", type=int, default=120) + ap.add_argument("--dir", default=os.path.join(D, "fixtures", "t14", "consumer")) + a = ap.parse_args() + root = os.path.join(D, "runs", "consumer-" + a.tag) + shutil.rmtree(root, ignore_errors=True) + os.makedirs(root) + dest = os.path.join(root, "dump") + log = os.path.join(root, "dump.log") + r = gate.dump_dir(a.dir, dest, "consumer_" + a.tag, a.port, a.stall, log) + gate.kill_office("consumer_" + a.tag) + names = sorted(f[:-5] for f in os.listdir(dest) if f.endswith(".json")) + summary = {} + for n in names: + d = json.load(open(os.path.join(dest, n + ".json"))) + summary[n] = {"loaded": "error" not in d or d.get("error") is None, "error": d.get("error"), "body_error": d.get("body_error")} + base = {n[:-5]: n for n in names if os.path.splitext(n)[0].endswith("-base")} + for n in names: + stem, ext = os.path.splitext(n) + twin = stem + "-base" + ext + if twin not in names: + continue + pa, pb = os.path.join(root, "pairA"), os.path.join(root, "pairB") + for p in (pa, pb): + os.makedirs(p, exist_ok=True) + shutil.copy(os.path.join(dest, n + ".json"), os.path.join(pa, n + ".json")) + shutil.copy(os.path.join(dest, twin + ".json"), os.path.join(pb, n + ".json")) + if os.path.isdir(os.path.join(root, "pairA")): + out = os.path.join(root, "pairs.json") + subprocess.run([sys.executable, "-I", os.path.join(D, "diff7.py"), os.path.join(root, "pairA"), os.path.join(root, "pairB"), out], + capture_output=True, check=True) + for n, v in json.load(open(out)).items(): + summary[n]["ndiff_vs_base"] = v["ndiff"] + summary[n]["diff_keys"] = [x[0] for x in v["diffs"][:8]] + json.dump(summary, open(os.path.join(root, "summary.json"), "w"), indent=1) + print("dump:", r) + for n in sorted(summary): + s = summary[n] + print("%-36s %-6s %s" % (n, "loaded" if s["loaded"] else "FAILED", ("diff vs base: %d %s" % (s["ndiff_vs_base"], s["diff_keys"][:3])) if "ndiff_vs_base" in s else "")) + bad = [n for n, s in summary.items() if not s["loaded"]] + print("not loaded:", bad) + sys.exit(1 if bad else 0) + + +main() diff --git a/test/odf-validate/fixtures/t14/chart.py b/test/odf-validate/fixtures/t14/chart.py new file mode 100644 index 0000000000..56dc2cc974 --- /dev/null +++ b/test/odf-validate/fixtures/t14/chart.py @@ -0,0 +1,74 @@ +#************************************************************** +# +# 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 fixtures/t14/chart.py <port> <outdir> <tag> +# A Calc document with a chart over A1:C4 (data in the sheet, so the chart uses the sheet's data provider), saved with the +# office's configured ODF version and reloaded. Writes <outdir>/<tag>.json: whether the reloaded chart has an internal data +# provider, the source range of every data sequence, and whether the chart still follows a changed cell. +import json, os, sys +import uno +from unohelp import connect, desktop, pv + +port, out, tag = int(sys.argv[1]), os.path.abspath(sys.argv[2]), sys.argv[3] +os.makedirs(out, exist_ok=True) +ctx = connect(port) +desk = desktop(ctx) + + +def info(doc): + sh = doc.Sheets.getByIndex(0) + cd = sh.Charts.getByName("chart1").EmbeddedObject + seqs = [] + cs = cd.FirstDiagram.CoordinateSystems[0] + for ct in cs.ChartTypes: + for ser in ct.DataSeries: + for seq in ser.DataSequences: + seqs.append((seq.Role if hasattr(seq, "Role") else seq.Values.Role, seq.Values.SourceRangeRepresentation)) + first = cd.DataProvider if hasattr(cd, "DataProvider") else cd.getDataProvider() + before = [seq.Values.getNumericalData() for ct in cs.ChartTypes for ser in ct.DataSeries for seq in ser.DataSequences] + sh.getCellByPosition(1, 1).Value = 12345 + after = [seq.Values.getNumericalData() for ct in cs.ChartTypes for ser in ct.DataSeries for seq in ser.DataSequences] + return {"internal_data_provider": bool(cd.hasInternalDataProvider()), "ranges": seqs, + "follows_cell": before != after, "label_sequences": [s.Label.SourceRangeRepresentation for ct in cs.ChartTypes for ser in ct.DataSeries for s in [ser.DataSequences[0]]] if False else None} + + +doc = desk.loadComponentFromURL("private:factory/scalc", "_blank", 0, (pv("Hidden", True),)) +sh = doc.Sheets.getByIndex(0) +for r, row in enumerate((("", "a", "b"), ("x", 1, 2), ("y", 3, 4), ("z", 5, 6))): + for c, v in enumerate(row): + cell = sh.getCellByPosition(c, r) + if isinstance(v, str): + cell.String = v + else: + cell.Value = v +addr = uno.createUnoStruct("com.sun.star.table.CellRangeAddress") +addr.Sheet, addr.StartColumn, addr.StartRow, addr.EndColumn, addr.EndRow = 0, 0, 0, 2, 3 +rect = uno.createUnoStruct("com.sun.star.awt.Rectangle", 6000, 500, 8000, 6000) +sh.Charts.addNewByName("chart1", rect, (addr,), True, True) +path = os.path.join(out, tag + ".ods") +if os.path.exists(path): + os.remove(path) +doc.storeToURL(uno.systemPathToFileUrl(path), (pv("FilterName", "calc8"), pv("Overwrite", True))) +doc.close(True) +d2 = desk.loadComponentFromURL(uno.systemPathToFileUrl(path), "_blank", 0, (pv("Hidden", True),)) +res = info(d2) +d2.close(True) +json.dump(res, open(os.path.join(out, "chart-" + tag + ".json"), "w"), indent=1, sort_keys=True) +print(tag, res) diff --git a/test/odf-validate/fixtures/t14/forms.py b/test/odf-validate/fixtures/t14/forms.py new file mode 100644 index 0000000000..e49c57d1bd --- /dev/null +++ b/test/odf-validate/fixtures/t14/forms.py @@ -0,0 +1,73 @@ +#************************************************************** +# +# 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 fixtures/t14/forms.py <port> <outdir> <tag> [file.odt] +# With a file nothing is created: the file is loaded and its InputRequired values are written. +# A Writer document with one form holding two controls of each type that can carry form:input-required: one with InputRequired +# set to false and one left at its default (true in AOO). Saved with the office's configured ODF version and reloaded. +# Writes <outdir>/forms-<tag>.json with the InputRequired value of every control after the reload (a control written with +# the wrong value, or without the attribute where the reader's default differs, changes value). +import json, os, sys +import uno +from unohelp import connect, desktop, pv + +port, out, tag = int(sys.argv[1]), os.path.abspath(sys.argv[2]), sys.argv[3] +os.makedirs(out, exist_ok=True) +ctx = connect(port) +desk = desktop(ctx) +KINDS = ["TextField", "CheckBox", "ComboBox", "ListBox", "RadioButton", "DateField", "TimeField", "FormattedField", "NumericField", + "CurrencyField", "PatternField", "DatabaseImageControl", "FileControl"] +if len(sys.argv) > 4: + path = os.path.abspath(sys.argv[4]) +else: + doc = desk.loadComponentFromURL("private:factory/swriter", "_blank", 0, (pv("Hidden", True),)) + forms = doc.DrawPage.Forms + form = ctx.ServiceManager.createInstanceWithContext("com.sun.star.form.component.Form", ctx) + forms.insertByName("f", form) + for i, k in enumerate(KINDS): + for suffix in ("", "_default"): + m = ctx.ServiceManager.createInstanceWithContext("com.sun.star.form.component." + k, ctx) + if m is None: + print("service not available:", k) + break + m.Name = k + suffix + if not suffix: + try: + m.InputRequired = False + except Exception as e: + print("no InputRequired on", k, e) + form.insertByIndex(form.Count, m) + path = os.path.join(out, tag + ".odt") + if os.path.exists(path): + os.remove(path) + doc.storeToURL(uno.systemPathToFileUrl(path), (pv("FilterName", "writer8"), pv("Overwrite", True))) + doc.close(True) +d2 = desk.loadComponentFromURL(uno.systemPathToFileUrl(path), "_blank", 0, (pv("Hidden", True),)) +f2 = d2.DrawPage.Forms.getByName("f") +res = {} +for i in range(f2.Count): + c = f2.getByIndex(i) + try: + res[c.Name] = c.InputRequired + except Exception as e: + res[c.Name] = "n/a" +d2.close(True) +json.dump(res, open(os.path.join(out, "forms-" + tag + ".json"), "w"), indent=1, sort_keys=True) +print(tag, res) diff --git a/test/odf-validate/fixtures/t14/handles.py b/test/odf-validate/fixtures/t14/handles.py new file mode 100644 index 0000000000..01252ae796 --- /dev/null +++ b/test/odf-validate/fixtures/t14/handles.py @@ -0,0 +1,74 @@ +#************************************************************** +# +# 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 fixtures/t14/handles.py <port> <outdir> <tag> [file.odg] +# With a file, nothing is drawn: the file is loaded and the handles of its shapes (in the order of the types below) are written. +# Draws custom shapes with polar handles (circular-arrow, block-arc, ring), with XY handles (sun, round-rectangle) and a ribbon +# in a Draw document, saves it with the office's configured ODF version, reloads the file and writes <outdir>/<tag>.json with the +# handles of every shape after the reload; the test compares the lists between the modes (v13 is the control). +import json, os, sys +import uno +from unohelp import connect, desktop, pv + +port, out, tag = int(sys.argv[1]), os.path.abspath(sys.argv[2]), sys.argv[3] +os.makedirs(out, exist_ok=True) +ctx = connect(port) +desk = desktop(ctx) +TYPES = ["circular-arrow", "block-arc", "ring", "sun", "round-rectangle", "ribbon", "mso-spt163", "smiley", "heart"] + + +def handles(shape): + geo = shape.CustomShapeGeometry + res = [] + for prop in geo: + if prop.Name == "Handles": + for h in prop.Value: + d = {} + for p in h: + v = p.Value + d[p.Name] = [(x.Value, x.Type) for x in (v.First, v.Second)] if hasattr(v, "First") else ((v.Value, v.Type) if hasattr(v, "Type") and hasattr(v, "Value") else v) + res.append(d) + return res + + +doc = None if len(sys.argv) > 4 else desk.loadComponentFromURL("private:factory/sdraw", "_blank", 0, (pv("Hidden", True),)) +page = doc.DrawPages.getByIndex(0) if doc else None +for i, t in enumerate(TYPES if doc else []): + sh = doc.createInstance("com.sun.star.drawing.CustomShape") + page.add(sh) + sh.Size = uno.createUnoStruct("com.sun.star.awt.Size", 4000, 3000) + sh.Position = uno.createUnoStruct("com.sun.star.awt.Point", 500 + (i % 3) * 5000, 500 + (i // 3) * 4000) + sh.CustomShapeGeometry = (pv("Type", t),) +if doc: + path = os.path.join(out, tag + ".odg") + if os.path.exists(path): + os.remove(path) + doc.storeToURL(uno.systemPathToFileUrl(path), (pv("FilterName", "draw8"), pv("Overwrite", True))) + doc.close(True) +else: + path = os.path.abspath(sys.argv[4]) +d2 = desk.loadComponentFromURL(uno.systemPathToFileUrl(path), "_blank", 0, (pv("Hidden", True),)) +page2 = d2.DrawPages.getByIndex(0) +res = {} +for i, t in enumerate(TYPES): + res[t] = handles(page2.getByIndex(i)) +d2.close(True) +json.dump(res, open(os.path.join(out, "handles-" + tag + ".json"), "w"), indent=1, sort_keys=True, default=str) +print(tag, {t: len(v) for t, v in res.items()}) diff --git a/test/odf-validate/fixtures/t14/make-consumer.py b/test/odf-validate/fixtures/t14/make-consumer.py new file mode 100644 index 0000000000..0894cf5c91 --- /dev/null +++ b/test/odf-validate/fixtures/t14/make-consumer.py @@ -0,0 +1,215 @@ +#************************************************************** +# +# 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-consumer.py -- (re)generate the ODF 1.4 consumer test packages in fixtures/t14/consumer/ +# make-consumer.py --report <val.json> -- print the table of validate.py results for them +# Each CASES entry is one construct from the ODF 1.4 additions inside an otherwise plain document, written with +# office:version and manifest:version 1.4, plus (unless base is None) a "<name>-base" control without the construct. +# "fields" fills the ${...} placeholders of the case; "base" overrides them for the control (all others become empty). +import json, os, struct, sys, zipfile, zlib +from string import Template + +OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "consumer") +NS = {"office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0", "style": "urn:oasis:names:tc:opendocument:xmlns:style:1.0", + "text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0", "table": "urn:oasis:names:tc:opendocument:xmlns:table:1.0", + "draw": "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0", "fo": "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0", + "svg": "urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0", "xlink": "http://www.w3.org/1999/xlink", + "chart": "urn:oasis:names:tc:opendocument:xmlns:chart:1.0", "number": "urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0", + "dc": "http://purl.org/dc/elements/1.1/", "meta": "urn:oasis:names:tc:opendocument:xmlns:meta:1.0"} +NSDECL = " ".join('xmlns:%s="%s"' % kv for kv in NS.items()) +MT = "application/vnd.oasis.opendocument." +# ext: (media type, body element, wrapper around the case's "content", has page layout and master page) +KINDS = {"odt": (MT + "text", "office:text", "${content}", True), + "ods": (MT + "spreadsheet", "office:spreadsheet", "${content}", True), + "odg": (MT + "graphics", "office:drawing", '<draw:page draw:name="page1" draw:master-page-name="Default">${content}</draw:page>', True), + "odc": (MT + "chart", "office:chart", "${content}", False)} +TXT = "<text:p>Plain text.</text:p>" +CELLS = '<table:table table:name="Sheet1"><table:table-column/><table:table-row>%s</table:table-row></table:table>' +RECT = 'svg:x="1cm" svg:y="1cm" svg:width="6cm" svg:height="4cm"' +GEOM = '<draw:custom-shape %s><draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="rectangle" ${eg}>${handles}</draw:enhanced-geometry></draw:custom-shape>' % RECT +FRAME = '<draw:frame draw:name="F1" text:anchor-type="paragraph" svg:width="4cm" svg:height="2cm"%s><draw:text-box%s><text:p>Box</text:p></draw:text-box></draw:frame>' +PSTYLE = '<style:style style:name="%s" style:family="paragraph">%s</style:style>' +GSTYLE = '<style:style style:name="%s" style:family="graphic">%s</style:style>' + + +def png(): + """A 1x1 RGB PNG.""" + def chunk(t, d): + c = t + d + return struct.pack(">I", len(d)) + c + struct.pack(">I", zlib.crc32(c) & 0xffffffff) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(b"\x00\xff\x00\x00")) + chunk(b"IEND", b"")) + + +def pstyle(n, tag): + """Paragraph style n with the given properties element.""" + return PSTYLE % (n, tag) + + +# name, ext, construct, content, auto (content.xml automatic styles), styles (styles.xml office:styles), fields, base +CASES = [ + dict(name="value-type-error", ext="ods", construct='office:value-type="error" with office:string-value, text:p child', + content=CELLS % '<table:table-cell ${c}><text:p>#DIV/0!</text:p></table:table-cell>', + fields={"c": 'office:value-type="error" office:string-value="#DIV/0!"'}, base={"c": 'office:value-type="string"'}), + dict(name="value-type-error-nostring", ext="ods", construct='office:value-type="error" without office:string-value, text:p child', + content=CELLS % '<table:table-cell ${c}><text:p>#N/A</text:p></table:table-cell>', + fields={"c": 'office:value-type="error"'}, base=None), + dict(name="extrusion-metal-type", ext="odg", construct='draw:extrusion-metal-type="draw:default" on draw:enhanced-geometry (value arbitrary: the RNG type is namespacedToken with a declared prefix)', + content=GEOM, fields={"eg": 'draw:extrusion="true" ${m}', "m": 'draw:extrusion-metal="true" draw:extrusion-metal-type="draw:default"', "handles": ""}, + base={"m": ""}), + dict(name="extrusion-specularity", ext="odg", construct='draw:extrusion-specularity="150%" on draw:enhanced-geometry', + content=GEOM, fields={"eg": 'draw:extrusion="true" draw:extrusion-specularity="${v}"', "v": "150%", "handles": ""}, base={"v": "80%"}), + dict(name="writing-mode-sideways", ext="odt", construct='style:writing-mode sideways-rl / sideways-lr on paragraph styles, sideways-rl on graphic-properties', + auto=pstyle("P1", '<style:paragraph-properties style:writing-mode="${w1}"/>') + pstyle("P2", '<style:paragraph-properties style:writing-mode="${w2}"/>') + + GSTYLE % ("G1", '<style:graphic-properties style:writing-mode="${w3}"/>'), + content='<text:p text:style-name="P1">One</text:p><text:p text:style-name="P2">Two</text:p><text:p>' + FRAME % (' draw:style-name="G1"', "") + '</text:p>', + fields={"w1": "sideways-rl", "w2": "sideways-lr", "w3": "sideways-rl"}, base={"w1": "lr-tb", "w2": "rl-tb", "w3": "lr-tb"}), + dict(name="break-even-odd-page", ext="odt", construct='fo:break-before="even-page" / "odd-page" on paragraph styles', + auto=pstyle("P1", '<style:paragraph-properties fo:break-before="${b1}"/>') + pstyle("P2", '<style:paragraph-properties fo:break-before="${b2}"/>'), + content='<text:p text:style-name="P1">Even</text:p><text:p text:style-name="P2">Odd</text:p>', + fields={"b1": "even-page", "b2": "odd-page"}, base={"b1": "page", "b2": "auto"}), + dict(name="vertical-rel-page-content", ext="odt", construct='style:vertical-rel="page-content-bottom" / "page-content-top" on graphic-properties of anchored text-box frames', + auto=GSTYLE % ("G1", '<style:graphic-properties style:vertical-pos="bottom" style:vertical-rel="${r1}"/>') + + GSTYLE % ("G2", '<style:graphic-properties style:vertical-pos="top" style:vertical-rel="${r2}"/>'), + content='<text:p>' + FRAME % (' draw:style-name="G1"', "") + FRAME.replace("F1", "F2") % (' draw:style-name="G2"', "") + '</text:p>', + fields={"r1": "page-content-bottom", "r2": "page-content-top"}, base={"r1": "page-content", "r2": "page-content"}), + dict(name="repeat-scale", ext="odg", construct='style:repeat="scale" on a graphic style with a bitmap fill', + auto=GSTYLE % ("gr1", '<style:graphic-properties draw:fill="bitmap" draw:fill-image-name="Img" style:repeat="${r}"/>'), + styles='<draw:fill-image draw:name="Img" xlink:href="Pictures/p.png" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/>', + content='<draw:rect draw:style-name="gr1" %s/>' % RECT, fields={"r": "scale"}, base={"r": "stretch"}, extra={"Pictures/p.png": png()}), + dict(name="svg-rx-ry", ext="odt", construct='svg:rx / svg:ry on a draw:text-box in a draw:frame and on an office:annotation', + content='<text:p>' + FRAME % ("", ' ${r}') + 'x<office:annotation ${r}><dc:creator>A</dc:creator><text:p>Note</text:p></office:annotation></text:p>', + fields={"r": 'svg:rx="0.3cm" svg:ry="0.2cm"'}, base={}), + dict(name="table-in-shape", ext="odg", construct='table:table as a child of draw:rect', + content='<draw:rect %s>${t}</draw:rect>' % RECT, + fields={"t": '<table:table table:name="T1"><table:table-column/><table:table-row><table:table-cell office:value-type="string"><text:p>cell</text:p></table:table-cell></table:table-row></table:table>'}, + base={"t": "<text:p>text</text:p>"}), + dict(name="annotation-in-page", ext="odg", construct='office:annotation as a child of draw:page', + content='<draw:rect %s><text:p>shape</text:p></draw:rect>${a}' % RECT, + fields={"a": '<office:annotation svg:x="8cm" svg:y="1cm" svg:width="4cm" svg:height="2cm"><dc:creator>A</dc:creator><dc:date>2026-11-03T10:00:00</dc:date><text:p>Note</text:p></office:annotation>'}, base={}), + dict(name="background-fill-paragraph", ext="odt", construct='style:background-fill (solid, #ff0000) in style:paragraph-properties', + auto=pstyle("P1", '<style:paragraph-properties>${bf}</style:paragraph-properties>'), + content='<text:p text:style-name="P1">Red</text:p>', fields={"bf": '<style:background-fill draw:fill="solid" draw:fill-color="#ff0000"/>'}, base={}), + dict(name="background-fill-cell", ext="ods", construct='style:background-fill (solid, #ff0000) in style:table-cell-properties', + auto='<style:style style:name="ce1" style:family="table-cell"><style:table-cell-properties>${bf}</style:table-cell-properties></style:style>', + content=CELLS % '<table:table-cell table:style-name="ce1" office:value-type="string"><text:p>Red</text:p></table:table-cell>', + fields={"bf": '<style:background-fill draw:fill="solid" draw:fill-color="#ff0000"/>'}, base={}), + dict(name="num-list-format", ext="odt", construct='number:num-list-format / number:num-list-label in office:styles, style:num-list-format-name on text:list-level-style-number', + styles='${nlf}<text:list-style style:name="L1"><text:list-level-style-number text:level="1" style:num-format="1" ${nm}/></text:list-style>', + content='<text:list text:style-name="L1"><text:list-item><text:p>One</text:p></text:list-item></text:list>', + fields={"nlf": '<number:num-list-format style:name="NLF1"><number:text>(</number:text><number:num-list-label text:level="1"/><number:text>)</number:text></number:num-list-format>', + "nm": 'style:num-list-format-name="NLF1"'}, base={}), + dict(name="filter-color-condition", ext="ods", construct='table:filter-condition with table:data-type="background-color" and "text-color", table:value a colour', + content=CELLS % '<table:table-cell office:value-type="string"><text:p>a</text:p></table:table-cell>' + + '<table:database-ranges><table:database-range table:name="DB1" table:target-range-address="Sheet1.A1:Sheet1.A3">' + '<table:filter><table:filter-and>${f}</table:filter-and></table:filter></table:database-range></table:database-ranges>', + fields={"f": '<table:filter-condition table:field-number="0" table:operator="=" table:data-type="background-color" table:value="#ff0000"/>' + '<table:filter-condition table:field-number="0" table:operator="=" table:data-type="text-color" table:value="#0000ff"/>'}, + base={"f": '<table:filter-condition table:field-number="0" table:operator="=" table:value="a"/>' + '<table:filter-condition table:field-number="0" table:operator="!=" table:value="b"/>'}), + dict(name="chart-major-origin", ext="odc", construct='chart:major-origin and chart:minor-logarithmic on style:chart-properties', + auto='<style:style style:name="ch1" style:family="chart"><style:chart-properties ${a}/></style:style>', + content='<chart:chart chart:class="chart:bar"><chart:plot-area><chart:axis chart:dimension="y" chart:name="primary-y" chart:style-name="ch1"/></chart:plot-area></chart:chart>', + fields={"a": 'chart:major-origin="0" chart:minor-logarithmic="true"'}, base={}), + dict(name="allow-overlap-decorative", ext="odt", construct='draw:allow-overlap and draw:decorative on style:graphic-properties', + auto=GSTYLE % ("G1", '<style:graphic-properties ${a}/>'), content='<text:p>' + FRAME % (' draw:style-name="G1"', "") + '</text:p>', + fields={"a": 'draw:allow-overlap="true" draw:decorative="true"'}, base={}), + dict(name="margin-gutter", ext="odt", construct='style:margin-gutter on style:page-layout-properties', + content=TXT, fields={"pl": 'fo:margin-left="2cm" style:margin-gutter="1cm"'}, base={"pl": 'fo:margin-left="2cm"'}), + dict(name="relative-tab-stop-position", ext="odt", construct='text:relative-tab-stop-position on style:paragraph-properties', + auto=pstyle("P1", '<style:paragraph-properties ${a}><style:tab-stops><style:tab-stop style:position="5cm"/></style:tab-stops></style:paragraph-properties>'), + content='<text:p text:style-name="P1">A<text:tab/>B</text:p>', fields={"a": 'text:relative-tab-stop-position="false"'}, base={}), + dict(name="start-value-zero", ext="odt", construct='text:start-value="0" on text:list-level-style-number', + auto='<text:list-style style:name="L1"><text:list-level-style-number text:level="1" style:num-format="1" text:start-value="${v}"/></text:list-style>', + content='<text:list text:style-name="L1"><text:list-item><text:p>Zero</text:p></text:list-item><text:list-item><text:p>One</text:p></text:list-item></text:list>', + fields={"v": "0"}, base={"v": "1"}), + dict(name="handle-new-attributes", ext="odg", construct='draw:handle with only draw:handle-position-x/-y, and a polar one with -polar-pole-x/-y, -polar-radius/-angle', + content=GEOM, fields={"eg": "", "handles": '<draw:handle draw:handle-position-x="10800" draw:handle-position-y="0"/>' + '<draw:handle draw:handle-polar-pole-x="10800" draw:handle-polar-pole-y="10800" draw:handle-polar-radius="5000" draw:handle-polar-angle="0"/>'}, + base={"handles": ""}), +] + + +def build(case, base): + mime, body, wrap, paged = KINDS[case["ext"]] + f = {k: "" for k in case["fields"]} + f.update(case["base"] if base else case["fields"]) + f = {k: Template(v).substitute(f) for k, v in f.items()} # fields may refer to other fields, one level + sub = lambda s: Template(s).substitute(f) + content = sub(Template(wrap).substitute(content=case["content"])) + head = '<?xml version="1.0" encoding="UTF-8"?>\n<%s ' + NSDECL + ' office:version="1.4">%s</%s>\n' + cbody = '<office:automatic-styles>%s</office:automatic-styles><office:body><%s>%s</%s></office:body>' % (sub(case.get("auto", "")), body, content, body) + sbody = '<office:styles>%s</office:styles>' % sub(case.get("styles", "")) + if paged: + sbody += ('<office:automatic-styles><style:page-layout style:name="PM1"><style:page-layout-properties %s/></style:page-layout></office:automatic-styles>' + '<office:master-styles><style:master-page style:name="Default" style:page-layout-name="PM1"/></office:master-styles>') % f.get("pl", "") + streams = {"content.xml": head % ("office:document-content", cbody, "office:document-content"), + "styles.xml": head % ("office:document-styles", sbody, "office:document-styles")} + entries = "".join('<manifest:file-entry manifest:full-path="%s" manifest:media-type="%s"/>' % (n, "image/png" if n.endswith(".png") else "text/xml") + for n in list(streams) + list(case.get("extra", {}))) + 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.4">' + '<manifest:file-entry manifest:full-path="/" manifest:version="1.4" manifest:media-type="%s"/>%s</manifest:manifest>\n') % (mime, entries) + path = os.path.join(OUT, case["name"] + ("-base" if base else "") + "." + case["ext"]) + with zipfile.ZipFile(path, "w") as z: + def put(n, data, comp): + z.writestr(zipfile.ZipInfo(n, (2026, 11, 3, 0, 0, 0)), data, compress_type=comp) + put("mimetype", mime, zipfile.ZIP_STORED) + for n, t in streams.items(): + put(n, t, zipfile.ZIP_DEFLATED) + for n, d in case.get("extra", {}).items(): + put(n, d, zipfile.ZIP_STORED) + put("META-INF/manifest.xml", mani, zipfile.ZIP_DEFLATED) + return path + + +def report(valjson): + val = json.load(open(valjson)) + count = {v: {} for v in ("13", "14")} + for v in count: + for e in val["errors"][v]: + # validate.py only rewrites a 1.2/1.3 version to the target, so every 1.4 file draws its three version errors under 1.3 + if v == "13" and e[3].startswith(('value of attribute "office:version"', 'value of attribute "manifest:version"')): + continue + count[v][e[0].split("/")[0]] = count[v].get(e[0].split("/")[0], 0) + 1 + rows = [] + for c in CASES: + for base in (False, True): + if base and c["base"] is None: + continue + n = c["name"] + ("-base" if base else "") + "." + c["ext"] + if n in val["packages"]: + rows.append((n, "(control without the construct)" if base else c["construct"], count["14"].get(n, 0), count["13"].get(n, 0))) + else: + rows.append((n, "NOT VALIDATED", -1, -1)) + w = max(len(r[0]) for r in rows) + print("%-*s %4s %4s construct (1.3 count leaves out the office:version/manifest:version errors)" % (w, "package", "1.4", "1.3")) + for n, k, e14, e13 in rows: + print("%-*s %4d %4d %s" % (w, n, e14, e13, k)) + + +if len(sys.argv) == 3 and sys.argv[1] == "--report": + report(sys.argv[2]) +else: + os.makedirs(OUT, exist_ok=True) + for c in CASES: + print(build(c, False)) + if c["base"] is not None: + print(build(c, True)) diff --git a/test/odf-validate/fixtures/t14/resave.py b/test/odf-validate/fixtures/t14/resave.py new file mode 100644 index 0000000000..03fe4214f7 --- /dev/null +++ b/test/odf-validate/fixtures/t14/resave.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: runpy.sh fixtures/t14/resave.py <port> <outdir> <tag> <input>... +# Loads each input hidden and saves it again (writer8, calc8, impress8, draw8 by extension) as <outdir>/<tag>-<name>, with whatever +# ODF version the running office is configured for. Used with an older office (ODFV_APP=/Applications/OpenOffice5.app) to see +# what it keeps of a file written in a newer ODF version. +import os, sys +import uno +from unohelp import FILTERS, connect, desktop, pv + +port, out, tag = int(sys.argv[1]), os.path.abspath(sys.argv[2]), sys.argv[3] +os.makedirs(out, exist_ok=True) +ctx = connect(port) +desk = desktop(ctx) +for src in sys.argv[4:]: + src = os.path.abspath(src) + doc = desk.loadComponentFromURL(uno.systemPathToFileUrl(src), "_blank", 0, (pv("Hidden", True), pv("ReadOnly", True))) + if doc is None: + print("could not load", src) + continue + dst = os.path.join(out, tag + "-" + os.path.basename(src)) + if os.path.exists(dst): + os.remove(dst) + doc.storeToURL(uno.systemPathToFileUrl(dst), (pv("FilterName", FILTERS[os.path.splitext(src)[1][1:]]), pv("Overwrite", True))) + doc.close(True) + print("resaved", os.path.basename(src)) diff --git a/test/odf-validate/fixtures/t14/run-in-mode.sh b/test/odf-validate/fixtures/t14/run-in-mode.sh new file mode 100755 index 0000000000..744674760f --- /dev/null +++ b/test/odf-validate/fixtures/t14/run-in-mode.sh @@ -0,0 +1,41 @@ +#!/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: run-in-mode.sh <strict|ext|v13|x13|v14|x14> <port> <outdir> <script.py> [args...] +# Starts an office whose profile (runs/profile_t14_<mode>) sets Save/ODF/DefaultVersion for the mode, runs the pyuno script in it +# and stops the office. Use a port that no gate is using. +source ${0:A:h}/../../env.sh +mode=$1; port=$2; out=$3; script=$4; shift 4 +case $mode in ext) v=3;; strict) v=4;; v13) v=5;; x13) v=6;; v14) v=7;; x14) v=8;; *) echo "run-in-mode.sh: unknown mode $mode" >&2; exit 2;; esac +prof=t14_$mode +lsof -nP -iTCP:$port -sTCP:LISTEN >/dev/null 2>&1 && { echo "run-in-mode.sh: port $port is in use" >&2; exit 2; } +mkdir -p $D/runs/profile_$prof/user +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 +$D/start.sh $prof $port || exit 2 +(cd $D && gtimeout 180 $D/runpy.sh $script $port $out $mode "$@") +rc=$? +pkill -f "profile_$prof"; sleep 2 +exit $rc diff --git a/test/odf-validate/gate.py b/test/odf-validate/gate.py index d2f1c96011..00d90e1299 100644 --- a/test/odf-validate/gate.py +++ b/test/odf-validate/gate.py @@ -267,7 +267,7 @@ class Gate: bad.append("shall_formulas %d > %d in --prev" % (r["shall_formulas"], pf)) return bad - def validate_dir(self, md, out, kinds_base, label): + def validate_dir(self, md, out, kinds_base, label, kinds_file="kinds.json"): """validate.py + report.py + the checkers on out/; returns a result dict.""" r = {} log = os.path.join(md, "validate.log") @@ -286,21 +286,23 @@ class Gate: r["shall_" + name] = int(m.group(1)) if m and o.returncode == 0 else None base = kinds_base if base and os.path.exists(base): - r["vs_baseline"] = run(PY + [os.path.join(D, "compare.py"), base, os.path.join(md, "kinds.json")], os.path.join(md, "compare_base.log")) + r["vs_baseline"] = run(PY + [os.path.join(D, "compare.py"), base, os.path.join(md, kinds_file)], os.path.join(md, "compare_base.log")) return r def validate(self): res = {} for mode in self.modes: md = os.path.join(self.run, mode) - r = self.validate_dir(md, os.path.join(md, "out"), os.path.join(D, "baseline", mode + ".kinds.json"), mode) - pk = self.prev_path(mode + "/kinds.json") + # the ODF 1.4 modes are measured against the 1.4 schema, the others against 1.2 as written + kf = {"v14": "kinds14.json", "x14": "kinds14x.json"}.get(mode, "kinds.json") + r = self.validate_dir(md, os.path.join(md, "out"), os.path.join(D, "baseline", mode + ".kinds.json"), mode, kf) + pk = self.prev_path(mode + "/" + kf) r["problems"] = self.shall_problems(r, self.prev_validate("validate", mode)) if not self.a.limit and not pk and "vs_baseline" not in r: r["problems"].append("no baseline/%s.kinds.json and no --prev kinds to compare with" % mode) if pk and not self.a.limit: - r["vs_prev"] = run(PY + [os.path.join(D, "compare.py"), pk, os.path.join(md, "kinds.json")], os.path.join(md, "compare_prev.log")) - r["vs_prev_reverse"] = run(PY + [os.path.join(D, "compare.py"), os.path.join(md, "kinds.json"), pk], os.path.join(md, "compare_prev_rev.log")) + r["vs_prev"] = run(PY + [os.path.join(D, "compare.py"), pk, os.path.join(md, kf)], os.path.join(md, "compare_prev.log")) + r["vs_prev_reverse"] = run(PY + [os.path.join(D, "compare.py"), os.path.join(md, kf), pk], os.path.join(md, "compare_prev_rev.log")) res[mode] = r res["ok"] = all(r.get("vs_baseline", 0) == 0 and r.get("vs_prev", 0) == 0 and r.get("vs_prev_reverse", 0) == 0 and not r["problems"] for r in res.values()) return res
