mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-13 16:34:14 +02:00
Merge pull request #1250 from ispyisail/feature/mcp-folio-presets
Add paper size presets for folios to the scripting API and MCP
This commit is contained in:
@@ -561,6 +561,22 @@ 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 size of the frame a PDF
|
||||
export measures, e.g. `23 columns of 70, 12 rows of 82; frame 1223.25 x
|
||||
791.25 pt` for tabloid landscape from a new folio. (The PDF export
|
||||
measures the frame and title block plus its one-pixel line, at 96 pixels
|
||||
an inch: 0.75 pt a pixel, and writes it on the standard sheet it is
|
||||
within 3 pt of.) Needs `qet.folioPresets()` in the build.
|
||||
- **The `wiring` export names unnamed terminals.** Most shipped symbols
|
||||
leave their terminals unnamed, so `from_terminal`/`to_terminal` are often
|
||||
empty. Each row also ends with `from_terminal_index`,
|
||||
|
||||
+36
-3
@@ -1483,7 +1483,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"]
|
||||
@@ -1793,6 +1800,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)}")
|
||||
@@ -1851,6 +1865,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 route == "avoid":
|
||||
# The wire exists either way; a route not found leaves it on the
|
||||
# default path, which is a note on the op, not a failure of it.
|
||||
@@ -3633,8 +3660,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 "
|
||||
|
||||
@@ -53,6 +53,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")
|
||||
@@ -506,6 +515,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."""
|
||||
@@ -4329,6 +4352,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
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
#include <QPdfDocument>
|
||||
#include <QPainter>
|
||||
#endif
|
||||
#include <QPageSize>
|
||||
#include <cmath>
|
||||
#include "../qetgraphicsitem/dynamicelementtextitem.h"
|
||||
#include "../qetgraphicsitem/independenttextitem.h"
|
||||
#include "../qetgraphicsitem/qetshapeitem.h"
|
||||
@@ -3840,9 +3842,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
|
||||
@@ -3853,6 +3926,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)
|
||||
{
|
||||
@@ -3862,8 +3938,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;
|
||||
}
|
||||
@@ -3874,7 +3950,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));
|
||||
|
||||
@@ -254,7 +254,18 @@ class QetGraphicsTableItem;
|
||||
which it does not, are left alone. Changing the project title is not
|
||||
undoable: the application sets it directly too.
|
||||
|
||||
A folio's title block @b template is a seventh, separate case:
|
||||
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 separate case again:
|
||||
Diagram::setTitleBlockTemplate() resolves a name only against
|
||||
QETProject::embeddedTitleBlockTemplatesCollection() -- the same
|
||||
copy-into-the-project step addElement() already does for elements,
|
||||
@@ -543,6 +554,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 --
|
||||
|
||||
Reference in New Issue
Block a user