diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index cd53ec880..5ec11667e 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -542,6 +542,21 @@ Python, plus the hang guard on `addConductor` and the database refresh in `qet.terminalIndex()` in the build. A symbol file saved without terminal uuids lists them empty; QElectroTech gives the terminals of every project's copy of it a uuid on opening (#1118), written on the next save. +- **A folio can be sized for a sheet of paper**: `set_folio_border` with + `"property": "preset"` and a value such as `"tabloid-landscape"` (A0–A5, + letter, legal, tabloid or ledger, each `-portrait` or `-landscape`) picks + the column and row counts and sizes that fill the sheet best without + going over it, as one undo step, keeping each size as near the folio's + current one as it can. The title block and headers are measured from the + folio, not assumed, so it holds for any template on either edge. Sizes + stay whole numbers, because the folio properties panel edits them in + whole pixels and would round a fraction off the first time it was + opened; so the page can come out up to 0.75 pt short of the sheet a + side. The op's `note` says what it chose and the page a PDF export + writes, e.g. `23 columns of 70, 12 rows of 82; PDF page 1223.25 x + 791.25 pt` for tabloid landscape from a new folio. (The PDF export makes + the page the frame and title block plus its one-pixel line, at 96 pixels + an inch: 0.75 pt a pixel.) Needs `qet.folioPresets()` in the build. - **`qet_export` isolates its launch.** SingleApplication keys its socket on `applicationFilePath()`, so a second launch of the same binary path forwards its request to an already-running instance and returns *that* diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 257b1c294..f559c05a7 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1479,7 +1479,14 @@ OPS = { SHAPES = ["line", "rectangle", "ellipse", "polygon"] FOLIO_BORDER_PROPERTIES = ["columns", "column-width", "display-columns", - "rows", "row-height", "display-rows"] + "rows", "row-height", "display-rows", "preset"] +# The sheets set_folio_border's "preset" sizes a folio for, as the scripting +# API names them (QetScriptApi::folioPresets()). Tabloid and ledger are the +# same 11 x 17 in sheet. +FOLIO_PRESETS = [f"{paper}-{orientation}" + for paper in ("a0", "a1", "a2", "a3", "a4", "a5", + "letter", "legal", "tabloid", "ledger") + for orientation in ("portrait", "landscape")] ELEMENT_TEXT_SOURCES = ["text", "info", "composite"] ELEMENT_TEXT_PROPERTIES = ["text", "source", "info", "composite", "frame", "size", "x", "y", "rotation", "width"] @@ -1772,6 +1779,13 @@ def _build_script(operations: list, output: str) -> str: raise ValueError(f"operation {i}: unknown folio border property " f"{op.get('property')!r}; expected one of " f"{', '.join(FOLIO_BORDER_PROPERTIES)}") + preset = name == "set_folio_border" and op.get("property") == "preset" + if preset: + if str(op.get("value", "")).lower() not in FOLIO_PRESETS: + raise ValueError(f"operation {i}: unknown folio preset {op.get('value')!r}; " + f"expected one of {', '.join(FOLIO_PRESETS)}") + # Only a build that has it can say why it refused one. + uuid_methods.add("folioPresets") if name == "add_element_text" and op.get("source") not in ELEMENT_TEXT_SOURCES: raise ValueError(f"operation {i}: unknown source {op.get('source')!r}; " f"expected one of {', '.join(ELEMENT_TEXT_SOURCES)}") @@ -1830,6 +1844,19 @@ def _build_script(operations: list, output: str) -> str: lines.append(f" var e{i} = qetMcpConductorEnd({i}, {args[0]}, {conductor_js});") call = f"(e{i} ? {call} : false)" lines.append(f" var v{i} = {call};") + if preset: + # Say what the preset chose, and the size of the frame and title + # block as a PDF export measures it: plus its one-pixel line, at + # 96 pixels an inch, in points. The export writes that on the + # standard sheet it is within 3 pt of. + f = args[0] + lines.append( + f" if (v{i}) qet.log({_js(_MARKER)} + JSON.stringify({{kind: 'op_note', " + f"index: {i}, note: qet.folioBorder({f}, 'columns') + ' columns of ' + " + f"qet.folioBorder({f}, 'column-width') + ', ' + qet.folioBorder({f}, 'rows') + " + f"' rows of ' + qet.folioBorder({f}, 'row-height') + '; frame ' + " + f"Math.ceil(Number(qet.folioBorder({f}, 'width')) + 1) * 0.75 + ' x ' + " + f"Math.ceil(Number(qet.folioBorder({f}, 'height')) + 1) * 0.75 + ' pt'}}));") if ident is not None: lines.append(f" R[{_js(ident)}] = v{i};") refs.add(ident) @@ -3591,8 +3618,14 @@ TOOLS = [ "where it is found. Returns the number of items changed; never " "matches an empty field. set_project_title renames the project. " "set_folio_border sets one " - "of the folio frame's " + ", ".join(FOLIO_BORDER_PROPERTIES) + - " (counts 1-99, sizes 1-1000, display-* true/false). " + "of the folio frame's " + ", ".join(FOLIO_BORDER_PROPERTIES[:-1]) + + " (counts 1-99, sizes 1-1000, display-* true/false), or \"property\": " + "\"preset\" with a sheet of paper as the value (" + + ", ".join(FOLIO_PRESETS) + "): it picks the column and row counts " + "and whole-number sizes that fill that sheet best without going " + "over it, allowing for the folio's own title block, as one undo " + "step; the op's note says what it chose and the frame's size in " + "points, which qet_export's pdf writes on that sheet exactly. " "set_conductor_default sets one of a folio's conductor defaults " "(Folio properties > Conductors): onetextperfolio (true/false, one " "wire number per potential on the folio) or any set_conductor " diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 0f7871168..ee9bd96ae 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -52,6 +52,15 @@ needs_elements = unittest.skipUnless(have_binary and have_elements, needs_examples = unittest.skipUnless(have_binary and have_examples, "set QET_BINARY and QET_EXAMPLES") + +def _pdf_page_size(path) -> tuple: + """The first page's MediaBox width and height, in points. QPdfWriter + writes page dictionaries uncompressed, so no PDF library is needed.""" + box = re.search(rb"/MediaBox\s*\[\s*([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)", + Path(path).read_bytes()) + x0, y0, x1, y1 = map(float, box.groups()) + return (x1 - x0, y1 - y0) + COIL = "common://10_electric/10_allpole/310_relays_contactors_contacts/01_coils/bobine_ka_a_remanence.elmt" SLAVE = ("common://10_electric/10_allpole/310_relays_contactors_contacts/" "02_contacts_cross_referencing/15_protection_contacts/contact_relais_nf_esclave.elmt") @@ -474,6 +483,20 @@ class EditValidation(unittest.TestCase): with self.assertRaises(ValueError): self.build([c]) + def test_folio_preset_is_checked_up_front_and_probed_for(self): + with self.assertRaisesRegex(ValueError, "unknown folio preset 'b4-portrait'"): + self.build([{"op": "set_folio_border", "folio": 0, "property": "preset", + "value": "b4-portrait"}]) + script = self.build([{"op": "set_folio_border", "folio": 0, "property": "preset", + "value": "Tabloid-Landscape"}]) + self.assertIn('qet.setFolioBorder(0, "preset", "Tabloid-Landscape")', script) + # a build without presets is told so, not left to fail on the call + self.assertIn('"folioPresets"', script) + self.assertIn("frame", script) + plain = self.build([{"op": "set_folio_border", "folio": 0, "property": "columns", + "value": "10"}]) + self.assertNotIn('"folioPresets"', plain) + def test_version_is_not_a_settable_folio_property(self): """setFolioProperty('version') reported success and was overwritten by the file-format stamp. It must stay refused on every layer.""" @@ -4200,6 +4223,55 @@ class Integration(unittest.TestCase): self.assertEqual(ch["displayrows"], ["true", "false"]) self.assertEqual(m.tool_project_info(r["output"])["title"], "Renamed") + def test_folio_preset_fits_the_sheet(self): + """A preset fills the sheet as nearly as whole-number sizes allow, + never over it, allowing for the title block, and the PDF export + writes the page on the sheet itself -- in landscape too, which it + did not until the export matched a wide page upright and turned it + (an A3 landscape folio was a 1190 x 841 pt page).""" + base = self.sb.new(folios=1) + # QPageSize's sheets, upright, in whole points + sheets = {"a0": (2384, 3370), "a1": (1684, 2384), "a2": (1191, 1684), + "a3": (842, 1191), "a4": (595, 842), "a5": (420, 595), + "letter": (612, 792), "legal": (612, 1008), "tabloid": (792, 1224), + "ledger": (792, 1224)} + self.assertEqual(sorted(f"{p}-{o}" for p in sheets for o in ("portrait", "landscape")), + sorted(m.FOLIO_PRESETS)) + for paper, (w, h) in sheets.items(): + for orientation in ("portrait", "landscape"): + name = f"{paper}-{orientation}" + with self.subTest(preset=name): + r = self.ok(self.sb.edit(base, [{"op": "set_folio_border", "folio": 0, + "property": "preset", "value": name}])) + note = r["operations"][0]["note"] + got = re.search(r"frame ([\d.]+) x ([\d.]+) pt", note) + self.assertIsNotNone(got, note) + sheet = (h, w) if orientation == "landscape" else (w, h) + for have, want in zip(map(float, got.groups()), sheet): + self.assertLessEqual(have, want, note) + self.assertGreater(have, want - 3, note) + pdf = Path(r["output"]).with_suffix(".pdf") + self.assertTrue(m.tool_export(BINARY, r["output"], "pdf", str(pdf))["ok"]) + self.assertEqual(_pdf_page_size(pdf), tuple(map(float, sheet)), note) + + def test_folio_preset_tabloid_is_the_requested_grid(self): + """From a default folio, tabloid landscape is 23 x 70 by 12 x 82: + the grid a user had worked out by hand, which the export writes on + a 1224 x 792 pt page. It keeps the folio's look -- 9 x 179 by 5 x 197 + would fill the sheet to the pixel, and is no use to anyone.""" + base = self.sb.new(folios=1) + r = self.ok(self.sb.edit(base, [{"op": "set_folio_border", "folio": 0, + "property": "preset", "value": "tabloid-landscape"}])) + ch = r["diff"]["folios"]["changed"][0]["changed"] + self.assertEqual((ch["cols"][1], ch["colsize"][1], ch["rows"][1], ch["rowsize"][1]), + ("23", "70", "12", "82")) + self.assertIn("frame 1223.25 x 791.25 pt", r["operations"][0]["note"]) + # one command: one undo puts the whole frame back + r = self.ok(self.sb.edit(base, [{"op": "set_folio_border", "folio": 0, + "property": "preset", "value": "tabloid-landscape"}, + {"op": "undo"}])) + self.assertFalse(r["diff"]["folios"].get("changed")) + def test_folio_frame_bounds_are_refused(self): """Counts are 1-99 and sizes 1-1000. 0 is what the application's own panel allows at the bottom, and was left refused rather than assumed diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index 54b0d9054..5f0dbfbf6 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -52,6 +52,8 @@ #ifdef QET_HAS_QTPDF #include #include +#include +#include #endif #include "../qetgraphicsitem/dynamicelementtextitem.h" #include "../qetgraphicsitem/independenttextitem.h" @@ -3714,9 +3716,80 @@ QString QetScriptApi::folioBorder(int folioIndex, const QString &property) const if (property == QLatin1String("rows")) return QString::number(b.rows_count); if (property == QLatin1String("row-height")) return QString::number(b.rows_height); if (property == QLatin1String("display-rows")) return b.display_rows ? QStringLiteral("true") : QStringLiteral("false"); + const QRectF r = diagrams.at(folioIndex)->border_and_titleblock.borderAndTitleBlockRect(); + if (property == QLatin1String("width")) return QString::number(r.width()); + if (property == QLatin1String("height")) return QString::number(r.height()); return QString(); } +namespace { +/// The sheets "preset" knows, by the name it takes before -portrait or +/// -landscape. Tabloid and ledger are the same 11 x 17 in sheet. +const QList> &folioPaper() +{ + static const QList> p{ + {QStringLiteral("a0"), QPageSize::A0}, {QStringLiteral("a1"), QPageSize::A1}, + {QStringLiteral("a2"), QPageSize::A2}, {QStringLiteral("a3"), QPageSize::A3}, + {QStringLiteral("a4"), QPageSize::A4}, {QStringLiteral("a5"), QPageSize::A5}, + {QStringLiteral("letter"), QPageSize::Letter}, {QStringLiteral("legal"), QPageSize::Legal}, + {QStringLiteral("tabloid"), QPageSize::Tabloid}, {QStringLiteral("ledger"), QPageSize::Tabloid}}; + return p; +} + +/** + The count (1-99) and whole size (20-200) of cells that fill @p available + without going over it. Any fill within two units of it will do -- a PDF + export rounds a page that close to a standard sheet up to the sheet + (QPageSize's fuzzy match allows 3 pt, and two units are 1.5 pt) -- and + among those the size nearest @p current wins, so the grid keeps the + look it had rather than turning into nine huge columns that happen to + divide the sheet exactly. Failing that, the fullest fill. False if not + even one cell of the smallest size fits. +*/ +bool fitCells(qreal available, qreal current, int &count, qreal &size) +{ + constexpr qreal slack = 2.0; + int best_n = 0, best_s = 0, best_fill = 0; + bool best_close = false; + for (int s = 20; s <= 200; ++s) { + const int n = qMin(99, int(std::floor(available / s + 1e-9))); + if (n < 1) continue; + const int fill = n * s; + const bool close = fill >= available - slack; + bool better; + if (close != best_close) + better = close; + else if (close && qAbs(s - current) != qAbs(best_s - current)) + better = qAbs(s - current) < qAbs(best_s - current); + else + better = fill > best_fill; + if (!best_n || better) { + best_n = n; + best_s = s; + best_fill = fill; + best_close = close; + } + } + if (!best_n) return false; + count = best_n; + size = best_s; + return true; +} +} // namespace + +/** + @brief QetScriptApi::folioPresets + The values setFolioBorder() takes for "preset". +*/ +QStringList QetScriptApi::folioPresets() const +{ + QStringList names; + for (const auto &paper : folioPaper()) + names << paper.first + QStringLiteral("-portrait") + << paper.first + QStringLiteral("-landscape"); + return names; +} + /** @brief QetScriptApi::setFolioBorder Change one field of a folio's frame through ChangeBorderCommand, as the @@ -3727,6 +3800,9 @@ QString QetScriptApi::folioBorder(int folioIndex, const QString &property) const tested, so it is left refused rather than assumed safe. The extremes that are offered (99 x 99 cells, widths from 1 to 1000) were exported to PNG and did not hang or crash. + + "preset" sets all four at once for a sheet of paper, as one command: + see the class comment. */ bool QetScriptApi::setFolioBorder(int folioIndex, const QString &property, const QString &value) { @@ -3736,8 +3812,8 @@ bool QetScriptApi::setFolioBorder(int folioIndex, const QString &property, const log(QStringLiteral("qet.%1: project is read-only").arg(caller)); return false; } - if (!folioBorderNames().contains(property)) { - log(QStringLiteral("qet.%1: unknown property '%2'; expected one of %3") + if (!folioBorderNames().contains(property) && property != QLatin1String("preset")) { + log(QStringLiteral("qet.%1: unknown property '%2'; expected one of %3, preset") .arg(caller, property, folioBorderNames().join(QStringLiteral(", ")))); return false; } @@ -3748,7 +3824,45 @@ bool QetScriptApi::setFolioBorder(int folioIndex, const QString &property, const const BorderProperties old_b = diagram->border_and_titleblock.exportBorder(); BorderProperties new_b = old_b; bool ok = false; - if (property == QLatin1String("columns") || property == QLatin1String("rows")) { + if (property == QLatin1String("preset")) { + const QString v = value.toLower(); + const int dash = v.lastIndexOf(QLatin1Char('-')); + const QString orientation = v.mid(dash + 1); + bool known = dash > 0 && (orientation == QLatin1String("portrait") + || orientation == QLatin1String("landscape")); + QSizeF mm; + if (known) { + known = false; + for (const auto &paper : folioPaper()) { + if (paper.first != v.left(dash)) continue; + mm = QPageSize(paper.second).size(QPageSize::Millimeter); + known = true; + } + } + if (!known) { + log(QStringLiteral("qet.%1: unknown preset '%2'; expected one of %3") + .arg(caller, value, folioPresets().join(QStringLiteral(", ")))); + return false; + } + if ((orientation == QLatin1String("landscape")) != (mm.width() > mm.height())) + mm.transpose(); + // Scene units are pixels at 96 per inch, as the PDF export draws + // them; the export adds one for the frame's own line. + const qreal page_w = std::floor(mm.width() / 25.4 * 96.0 + 1e-6) - 1; + const qreal page_h = std::floor(mm.height() / 25.4 * 96.0 + 1e-6) - 1; + // Whatever is not columns or rows -- the headers, and the title + // block on whichever edge it sits -- measured rather than assumed, + // since it depends on the template. + const QRectF r = diagram->border_and_titleblock.borderAndTitleBlockRect(); + const qreal extra_w = r.width() - old_b.columns_count * old_b.columns_width; + const qreal extra_h = r.height() - old_b.rows_count * old_b.rows_height; + if (!fitCells(page_w - extra_w, old_b.columns_width, new_b.columns_count, new_b.columns_width) + || !fitCells(page_h - extra_h, old_b.rows_height, new_b.rows_count, new_b.rows_height)) { + log(QStringLiteral("qet.%1: preset '%2' leaves no room for a column or a row " + "beside this folio's title block").arg(caller, value)); + return false; + } + } else if (property == QLatin1String("columns") || property == QLatin1String("rows")) { const int n = value.toInt(&ok); if (!ok || n < 1 || n > 99) { log(QStringLiteral("qet.%1: %2 must be a whole number from 1 to 99, not '%3'").arg(caller, property, value)); diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index a458149da..1aec082d6 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -254,6 +254,17 @@ class QetGraphicsTableItem; which it does not, are left alone. Changing the project title is not undoable: the application sets it directly too. + A seventh, write-only field, "preset", sizes the frame for a sheet of + paper: "a4-landscape", "tabloid-portrait" and so on (folioPresets() + lists them). It picks the column and row counts and whole-number + sizes that fill the sheet best without going over it, keeping each + size as near the current one as it can, and pushes them as one + ChangeBorderCommand. Whole numbers because the folio properties panel + edits these sizes in whole pixels: a fraction would be rounded off the + first time someone opened it. Two read-only fields, "width" and + "height", are the frame and title block together in scene units -- + what an export draws -- so a caller can check the result. + A folio's title block @b template is a seventh, separate case: Diagram::setTitleBlockTemplate() resolves a name only against QETProject::embeddedTitleBlockTemplatesCollection() -- the same @@ -538,6 +549,7 @@ class QetScriptApi : public QObject Q_INVOKABLE bool setProjectTitle(const QString &title); Q_INVOKABLE QString folioBorder(int folioIndex, const QString &property) const; Q_INVOKABLE bool setFolioBorder(int folioIndex, const QString &property, const QString &value); + Q_INVOKABLE QStringList folioPresets() const; // -- the conductor defaults of a folio (Folio properties > Conductors), // or with folioIndex -1, the project's defaults for new folios --