qet-mcp: size a folio for a sheet of paper (set_folio_border preset)

setFolioBorder(folio, "preset", "tabloid-landscape") picks the column and
row counts and whole-number sizes that fill the sheet best without going
over it, as one ChangeBorderCommand. The title block and headers are
measured from the folio, so it holds for any template on either edge.
Sizes stay whole because the folio properties panel edits them in whole
pixels. Among fills within 1.5 pt of the sheet, the size nearest the
current one wins: from a new folio tabloid landscape is 23 x 70 by 12 x 82,
not 9 x 179 by 5 x 197. A0-A5, letter, legal, tabloid/ledger, each
-portrait or -landscape; folioPresets() lists them, and folioBorder() reads
"width" and "height" of the frame and title block.

Every preset exports on its sheet; landscape ones rely on the previous
commit, which turns a wide page before matching it to a sheet.

The MCP server checks the name up front, probes for folioPresets(), and
the op's note says what was chosen and the frame's size in points.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-02 22:26:14 +13:00
parent 797d502c0a
commit 0b4fc8beb0
5 changed files with 252 additions and 6 deletions
+15
View File
@@ -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*
+36 -3
View File
@@ -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 "
+72
View File
@@ -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
+117 -3
View File
@@ -52,6 +52,8 @@
#ifdef QET_HAS_QTPDF
#include <QPdfDocument>
#include <QPainter>
#include <QPageSize>
#include <cmath>
#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<QPair<QString, QPageSize::PageSizeId>> &folioPaper()
{
static const QList<QPair<QString, QPageSize::PageSizeId>> 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));
+12
View File
@@ -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 --