Compare commits

...
Author SHA1 Message Date
Álvaro Tejero Cantero 8613e66115 Let a personal access token reach /dbg
`/dbg` read only `::session/profile-id`, so scripted access meant logging
in with a password to obtain a cookie. A middleware after `actoken/authz`
fills `::session/profile-id` in from the token when there is no session,
which keeps one key for every existing handler to read and makes a
console session, keyed by profile, resolve the same way either way.

The admin gate is unchanged: devenv, or an address in `:admins`. What
changes is that a token now reaches those handlers where before only a
browser session did.

This stays off `graph-backend`. It is a change to what reaches an
admin-gated surface, and a reviewer should get to weigh it on its own
rather than find it inside a graph change.

AI-assisted-by: mixed models
2026-08-18 23:30:20 +02:00
Álvaro Tejero Cantero 6bd1af4f4a Record the applied transforms in GraphMeta
Ingestion is a partial port, so a graph this backend writes has had only
some of the pipeline applied. Rather than have a reader infer which from
a build version, the build writes it down: `GraphMeta.transforms` names
every transform id applied, projection-time denormalizations included,
and the parity consumer computes the complement and runs only that.

The ids cross a language boundary as data, so they are kebab-case
strings rather than keywords and must stay byte-identical to the
consumer's own list.

This stays off `graph-backend`. Nothing in this repository reads the
column, and an unread column is weight a reviewer is right to question.

AI-assisted-by: mixed models
2026-08-18 23:30:20 +02:00
Álvaro Tejero Cantero 13229dd94e Fill unset graph columns from the consumer's field defaults
A Penpot file omits an attribute whose value equals its default, so a
shape that is not blocked carries no `blocked` key at all. Projected as
it stands, that column reads NULL, and a reader cannot tell a missing
feature from a false one.

`app.graph.schema.beadpot` reads the schema manifest the parity consumer
exports, checked in at `backend/resources/app/graph/beadpot-schema.json`,
and `nodes/apply-defaults` fills every unset column that the manifest
gives a default for. The default is withheld when the column's Ladybug
type differs from the manifest's, because a default is expressed in its
column's type.

Applied after validation: a default belongs to the graph column, not to
the Penpot schema the attributes were checked against.

This stays off `graph-backend`. Penpot should declare these defaults
from its own model rather than import another project's manifest, which
is W24 on the workplan.

AI-assisted-by: mixed models
2026-08-18 23:30:20 +02:00
Andrey Antukh 9432c61637 📚 Document graph experiment architecture
Add Serena memory coverage for the embedded Ladybug graph subsystem.\nDocument projection, incremental sync, console data flow, tests, and operational risks.\n\nAI-assisted-by: gpt-5.6-luna
2026-08-18 08:44:51 +00:00
Álvaro Tejero Cantero f3c69f909f 📎 Apply the project formatter to the graph namespaces
`cljfmt check src/ test/` is a step of the Backend workflow and these two
files did not pass it: an import block sorted the way a human reads it
rather than the way the formatter sorts it, and a `cond` in
`format-typed-value` indented one column short.

Formatter output only. No semantic change.

AI-assisted-by: mixed models
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero be50b5e42d ♻️ Rename app.graph.project to app.graph.projection
`project` is a Penpot noun: a team holds projects and a project holds
files, and the graph will carry a `Project` node table. A namespace
called `app.graph.project.document` therefore reads as "the graph of a
Penpot project" and means the opposite.

`projection` is the word the rest of the subsystem already uses for the
operation: `projection-data`, `load-projection!`, `:projection` in the
ingest report, and `app.graph.schema.projection`.

Pure rename. Both namespaces and every alias move; nothing else changes.

AI-assisted-by: mixed models
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 912b7b3f14 🐛 Let the graph view's query filter follow the graph
"Show result in graph view" froze the set of node ids the query returned
and filtered every later repaint against it. Live sync creates ids the
set has never seen, so a shape created while a filter was on could not
appear in the view at any point, and clicking "Show full graph" was the
only way to see it. A node the query would no longer match stayed.

Keep the query beside the ids and re-run it whenever the graph repaints,
which is only when the projection actually changed. A failed re-run
keeps the ids in hand and says so on the status line rather than passing
a stale view off as current.

`idsInResult` and `presentIds` are extracted from the two places that
scraped UUIDs out of a result.

Verified in the devenv: with a filter showing 108 of 276 nodes, a
`:file-change` adding a Frame published on the session's msgbus topic
took the view to 109 of 277, with the new node carrying its added mark,
and no interaction.

AI-assisted-by: mixed models
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 4c21d59763 🐛 Build a synced page node the way the projection does
`apply-add-page` sent the new Page node through `nodes/validate-node`,
which checks a map against the registry schema and returns it unchanged.
Every other node on both write paths goes through
`nodes/project-attrs`, which also selects the projected keys and is the
single place a column-level rule can live. A rule added there reached a
rebuilt page and not a synced one.

AI-assisted-by: mixed models
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 9d9a72c8fb 🐛 Keep a synced graph equal to a rebuilt one
Cold projection and incremental sync are two implementations of one
mapping and nothing checked that they agree. They did not.

`backend-tests.graph-sync-parity-test` projects a file into one
`:memory:` database, applies a change list to that database and the same
list to the file data, projects the result into a second database, and
diffs the two down to the row and the column. It found four
disagreements, each fixed here.

**Sibling order was inverted.** A container's stored `:shapes` list runs
bottom to top and `IsChildOf.position` numbers children in Penpot
z-order, so appending to the list means taking position 0 and pushing
every sibling up. Sync instead handed each new child the next free
number, so any container edited live carried its children in the
opposite order to a rebuild, and a delete left a gap where a rebuild
renumbers densely. `insert-position` and `renumber-siblings` put the two
paths on the same rule for `:add-obj`, `:mov-objects` and `:del-obj`,
including a block move and `:after-shape`.

**A moved shape kept its old parent.** `:mov-objects` moved the edge and
left the shape's own `parent_id` and `frame_id` columns pointing at the
container it came from. Both now follow, and `frame_id` follows through
the whole subtree the shape carries, as
`app.common.files.changes` does for `:mov-objects`. A top-level shape's
column holds `uuid/zero`, the page's root frame, while its edge points
at the Page.

**A container's `shapes` column went stale.** Nothing maintained it
after an add, a move or a delete. It is now rebuilt from the sibling
order on every change that touches a container.

**Pages came out backwards.** `projection-data` reversed `:pages` before
numbering them, which is right for child shapes and wrong for pages:
`:pages` is the tab order and has no second ordering to undo. `Page.index`
and the page's `IsChildOf.position` are now that order.

One defect the test does not reach, fixed on the way past:
`index-add-shape!` accepted `:component-ctx` and dropped it, so a shape
added under an instance head added in the same session inherited no
`component-id`.

AI-assisted-by: mixed models
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero ca01381e1e Refuse a mutating query from the graph console
`debug/query-session!` ran whatever it was handed against the session
connection. A session graph is a projection of a file, rebuilt from that
file by Reload, so a mutation from the console produces a graph no
rebuild reproduces and no query result explains.

Bind the statement against the live schema first. A statement that does
not bind reports the binder's own message and executes nothing, which
also turns a misspelt table or property into an immediate error instead
of an empty result. A statement that binds runs only when the engine's
own read/write analysis calls it read-only.

The console's query box is labelled read-only. Load, Reload, Unload and
live sync are unaffected: they are separate handlers and do not go
through this path.

AI-assisted-by: mixed models
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 9eb0521b2f 📚 State what the graph schema does, not what it mirrors
The graph namespaces explained themselves by citing a separate project
whose Python pipeline reads the graphs this backend writes. A reader of
this repository does not have that project and should not need it, and a
docstring that justifies a choice by pointing elsewhere cannot be checked
here.

Every claim survives; only the framing changes. Column names and types
are Penpot's own decision, recorded with the reason for each divergence
from the snake_case default. The transform registry describes the edges
it materializes. The denormalizations in `app.graph.project.document`
are justified by the walk already holding both answers.

Three corrections fall out of the rewrite:

- `app.graph.schema.contract` claimed a test, `graph_contract_test`,
  that walks a checked-in schema manifest and fails on any divergence.
  No such test exists. The paragraph is gone.
- `app.graph.project.document` pointed at
  `app.graph.meta/projection-transforms`, which does not exist.
- `app.graph.project.transforms/registry` claimed its entries were "in
  application order" while `apply-transforms!` reduced over the literal
  vector. The three registered transforms read disjoint columns, so the
  order is not load-bearing. The docstring now says so, and the one real
  ordering constraint is stated where it applies: `link-swap-slots!`
  strips `swap-slot-*` entries from `touched`, so anything reading
  `touched` has to run before it.

`contract/pending-beadpot-columns` becomes `contract/unprojected-keys`.
It is referenced nowhere else.

AI-assisted-by: mixed models
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 10a2680766 🐛 Let the engine quote the Arrow field names it interpolates
`node-batch` named every top-level Arrow field with backticks, so that a
column whose name is a reserved word (`Page.index`, `Document.options`)
survived the DDL Ladybug generates for a staged table. The engine now
quotes those identifiers itself, and it does not collapse a doubled
backtick, so a pre-quoted name reaches the parser as ``index`` and
`createArrowTable` fails outright:

    Parser exception: mismatched input '``' expecting PRIMARY

Name the fields with `column-name`. The `COPY` projection is Cypher
rather than DDL and keeps its own backticks through
`cypher-property-key`, and STRUCT member names keep theirs too: those
come out of `LogicalType::toString()`, which the DDL builder does not
touch, so an unquoted member called `column` still fails to parse.

Measured with `probes/arrow/probe25.clj` against lbug 0.19.1: a plain
top-level reserved word loads and reads back, a pre-quoted one fails to
parse, a plain STRUCT member fails to parse, and a pre-quoted one loads
and reads back.

Also re-dates the engine facts in the `app.graph.arrow` docstring to the
version they were checked against, drops the SIGSEGV note from
`->param-value` now that `Connection.execute` rejects an unwrapped
parameter, and removes two references to the CSV loader.

AI-assisted-by: mixed models
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 761116188a Gate every sync statement template through the binder
Nothing checked that the eleven Cypher templates `app.graph.sync` emits
still bind against the DDL the schema registry generates. A renamed
column, a dropped table or a reserved word emitted unquoted surfaced only
when a live session ran the statement, and by then the batch's earlier
mutations had committed.

`backend-tests.graph-binder-gate-test` opens a `:memory:` database,
creates the live schema on it, and *prepares* one instance of each
template without executing any of them. 14 tests, 51 assertions: the
eleven templates, label coverage over all twelve registered node tables,
and two assertions on the gate itself, that a `RETURN` reads as read-only
and a `SET` does not, and that an unbindable statement is reported rather
than thrown.

It was not green on HEAD: it caught `set-document-revision-statement`
writing a column that no longer exists, fixed in the previous commit. Red
on both injected templates tried.

No `:jvm-opts` change: CI's `-M:dev:test` already carries the native
access flags the engine needs.

AI-assisted-by: mixed models
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero cf9e33a827 🐛 Write the document revision to the column that exists
`set-document-revision-statement` emitted `SET d.revn`, but the column is
`revision`: the beadpot contract renames `:revn` and the DDL has followed
it since. The statement is the last one in every sync batch, so each
batch raised after its mutations had already committed, and the session's
in-memory index stayed frozen at its load-time revision.

Name the column through `nodes/cypher-property-key` rather than spelling
it, so the DDL and the statement cannot disagree again.

Found by the binder gate in the next commit, on its first run.

AI-assisted-by: mixed models
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 94b8b5ff69 Add a prepared-statement surface to the graph connection
`app.graph.ladybug` could only run Cypher as text. Every value the sync
path writes is therefore concatenated into the statement, and nothing can
ask the engine whether a statement is even valid without running it.

Add the four functions that close both gaps. `prepare-on-connection!`
parses and binds without executing. `execute-prepared!` binds a parameter
map and runs it. `exec-prepared-on-connection!` prepares every statement
in a batch before executing any of them, so a parse or bind failure
aborts before the first mutation. `validate-on-connection!` returns
`{:ok? :error :read-only?}` instead of raising, which is what a gate
wants.

`->param-value` is the only `Value` constructor on the write path. It is
unconditional: on lbug 0.18.2 an unwrapped parameter does not raise, it
SIGSEGVs the JVM inside `lbug_value_clone`. Parameters are scalars only,
because the JNI `Value` constructor takes no list or map, so `MAP`,
`STRUCT` and `T[]` columns stay literal-rendered and the `:else` branch
raises rather than crashing.

Two departures from the design, both closing a JNI-handle leak on the
error path: `prepare-on-connection!` closes the failed
`PreparedStatement` before raising, and `execute-prepared!` closes every
`Value` it built, including the ones built before a later parameter was
rejected.

`as-statement` accepts a bare string, so the sync builders can convert to
bound parameters one family at a time rather than in one commit.

AI-assisted-by: mixed models
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 0660d91c83 Bulk load through in-memory Arrow; delete the CSV loader
app.graph.arrow stages rows as Arrow VectorSchemaRoots and COPYs from them. No file is written at any point and no value is rendered as text for the engine to re-parse, so the defect class that produced three of this branch's four backend defects cannot recur.

app.graph.bulk is deleted whole. csv-representable?, defer-to-cypher?, multiline?, fixup-statements, ladybug-literal, ladybug-list-element, ladybug-list-cell and staging-dir go with it, along with the post-COPY Cypher pass that emitted one SET per row.

Measured before deciding: the fixup pass was ~77% execution, 16-22% parse and 6-7% round-trip, and prepared statements could not have recovered any of it — every fixup row carries a MAP column and Ladybug binds scalars only. So this replaces rather than optimizes. Marginal ingest 4.0 -> 1.21 ms/shape; ~25 s extrapolated at 20k shapes against the ~2 min the CSV path projected. Size unchanged.

Four engine facts the implementation rests on, each verified against 0.18.2 with a standalone probe:

- An Arrow table is not a COPY source identifier but is a MATCH-able node label.
- A MAP vector's entries child must be a non-nullable struct, and MapVector.getWriter promotes it to a sparse union, so map vectors are built from an explicit Field and filled child-first.
- Ladybug names a staged table's columns and struct fields from the Arrow field names and quotes none of them, so anything needing quotes must arrive quoted — hence cypher-property-key, not column-name, names the Arrow fields.
- createArrowRelTable cannot resolve endpoints against a UUID-keyed node table under any encoding, so edges stage as a node table and the COPY subquery joins them.

values/coerce is reused unchanged, so the Arrow and Cypher writers cannot disagree about a value's shape; nodes/column-map-key-fn is extracted so they cannot disagree about a MAP's key spelling either.

Verified with pytest --graph-origin=penpot-only unchanged at 225/38/1 and --graph-origin=penpot unchanged at 258 passed / 2 pre-existing failures, both baselines re-established against a reverted backend rather than assumed; with bp graph diff between a CSV-built and an Arrow-built graph reporting "Graphs agree"; and with an adversarial round-trip carrying a quote, a backslash, a newline, a CRLF and a tab through STRING, STRING[] elements and MAP values.

The diff was necessary, not belt-and-braces: both parity suites passed an earlier revision of this change that was writing EDN into every JSON column, because beadpot's assertions never parse those columns. It also showed Arrow correcting a CSV defect — an empty Component.path was being stored as NULL, because Ladybug's CSV reader cannot distinguish an empty field from an absent one.
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 2d0e371ddb Add the Arrow prerequisites for in-memory bulk load
lbug pulls arrow-memory-core and arrow-vector but no allocation-manager implementation, so RootAllocator cannot be constructed; arrow-memory-netty 18.2.0 matches the arrow-vector lbug already brings and pulls only netty-buffer, netty-common, jackson and slf4j-api, all of which the backend already has.

--add-opens=java.base/java.nio=ALL-UNNAMED is the second half: without it MemoryUtil's static initializer dies with an InaccessibleObjectException that surfaces as an unhelpful NoClassDefFoundError from anything touching RootAllocator. It has to be present at JVM start, hence all three places. Note app.main/restart will not pick it up — it restarts integrant inside the same JVM, so the process must be restarted.

Worth a reviewer's attention: this is a JVM-wide flag added for one subsystem. It is the standard Arrow requirement and grants nothing beyond reflective access to java.nio, but it strengthens the case for putting the whole graph subsystem behind a feature flag.
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 02c91e521d Add the file-level graph columns and tighten the svg ones
Split out of "🐛 Declare the shape attributes stored files carry",
which is now #11125 and carries only its `common/` half. This commit is
the graph's own side of that change, and it stays on this branch.

`app.graph.schema.contract` pins `svg_viewbox` to `DOUBLE[4]` and
`svg_transform` to `DOUBLE[6]`. The shape schema types both `:map` on
purpose, because legacy files hold them as plain maps rather than as
`::grc/rect` and `::gmt/matrix` records, and a tighter *schema* would
reject those files. A tighter *column* costs nothing, since
`app.graph.schema.values/coerce` reads either form.

`app.graph.schema.nodes` declares four file-level attributes as
projection `:extra` rather than in `ctf/schema:file`: `:options`,
`:backend`, `:comment-thread-seqn`, and `:ignore-sync-until`. Declaring
them in the file schema breaks saving, measured at 185 failures, because
`app.binfile.common/update-file!` derives its UPDATE column list from a
file map's keys and the `file` table has no `backend` column, that value
being synthesized on read. An `:extra` is local to the graph and cannot
reach a write.

`app.graph.project.document` lifts `:options` out of `:data` before the
blob is dropped, so a consumer reads file-level configuration without
opening the blob.

AI-assisted-by: mixed models
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 1479c1a2ca Type graph columns as tightly as Ladybug allows
Ladybug is schema-first and strongly typed: a property key gets its type
at table-creation time and there is no widening later. That makes the
Malli to Ladybug mapping the whole of the graph's typing, and it was
leaving a lot on the table: a transform stored as `STRING`, a rect as
`JSON`, a set of feature flags as a single `STRING`. A column typed
`DOUBLE[4]` is four numbers a consumer reads as a tensor row; the same
value as JSON is text somebody has to parse and trust.

`app.graph.schema.types` now maps, in order: scalars; Penpot value types
whose layout is fixed even though Malli only sees a map or a string
(`::gmt/matrix` to `DOUBLE[6]`, `::gpt/point` to `DOUBLE[2]`,
`::grc/rect` to `DOUBLE[4]`, `::clr/hex-color` to `UINT32`); then
structure, with collections to `T[]`, `:map-of` to `MAP(k, v)`, and a
closed map of scalars to a `STRUCT`. JSON is the fallback of last
resort, for schemas that genuinely admit more than one shape.

Two defects fell out. `::sm/set` was unmapped, so `features` and
`migrations` were single strings rather than `STRING[]`, and
`::sm/one-of`, how Penpot spells a closed set of keywords, was unmapped
too, so `blend-mode`, `grow-type`, the constraints and every `layout-*`
were mistyped.

A tight column is only worth having if the writer fills it in that
shape, so `app.graph.schema.values` shapes a value for its type: a
matrix record into six doubles, a hex colour into a packed integer, a
map into a struct's fields. Both writers go through it, so the bulk load
and the incremental sync cannot disagree. What that required:

- STRUCT field names must be backticked in the DDL *and* in every
  literal, because a grid cell has a field named `column`. The catalog
  reports them bare.
- A struct literal's type is its field list, so every declared field
  must appear, and an absent one needs `cast(NULL, '<type>')`. A bare
  NULL is typed STRING and changes the struct's type.
- `STRUCT(…)[]` starts with `STRUCT(` but is a list, so the list check
  comes first.
- Nested lists cannot be rendered with `str`: Clojure's `[1 2]` is
  space-separated and Ladybug reads it as a one-element array.

Three more corrections in the same area:

- `project-attrs` used truthiness where it meant `some?`, so `opacity 0`
  and `blocked false` projected as absent.
- Set-valued columns are written sorted. A set has no order, so the
  column varied between builds of the same file, which is precisely what
  stops two builds being diffable.
- An empty collection is written as `[]` rather than skipped. A shape
  with no fills has none; NULL would say "unknown".

Renamed the `kuzu-*` helpers to `ladybug-*`: Kùzu is deprecated and
Ladybug substitutes it, so a name bearing the engine should bear this
one. The one remaining mention cites the upstream issue Ladybug
inherits.

AI-assisted-by: mixed models
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero b3e9f72d09 🐛 Write graph values Ladybug's CSV reader cannot carry through Cypher
Three parity failures against beadpot's suite, all one cause: the bulk loader
put compound and multi-line values into CSV, where Ladybug parses a field's
*contents* as a literal with no escape mechanism at all. Verified against
0.18: a comma inside a list element ends the element, quotes are kept as part
of the value rather than delimiting it, and the parallel reader rejects
quoted newlines outright.

So a value now goes through CSV only if it cannot be misread there — UUIDs,
numbers, booleans, single-line strings, and lists of those. Everything else
(MAP, STRUCT, STRING[]/JSON[], any string containing a newline) is written
after the COPY by one Cypher statement per row, where `app.graph.ladybug`
escapes properly. Parquet removes the distinction entirely and is still the
right destination (masterplan P0 T1); this is what CSV can honestly do.

Consequences beyond the encoding:

- `touched` entries reached the graph as `:swap-slot-…`, keywords stringified
  with their colon, so `LinkSwapSlots` matched nothing. Keywords now render
  through `name`.
- Shape names lost their newlines to a flattening step that existed only to
  keep the CSV writer happy. They are preserved.
- `applied_tokens` keys are rendered camelCase, the form Penpot's own JSON
  encoder produces and the one beadpot's `AppliedTokenKey` holds — a MAP
  column's keys are values, not schema, so they are not snake_cased.
- `link-component-instances!` keys on `component-file`, not `component-id`
  alone. The projection denormalizes `component-id` down the shape tree, after
  which it no longer tells an instance head from a shape inside one, and the
  transform linked every descendant frame; `ctk/instance-of?` requires both
  keys anyway. IsInstanceOf on the variants fixture: 78 -> 60, matching
  beadpot exactly.

`app.graph.schema.nodes/format-column-value` is now the single place that
knows a column's type and its contract details, used by the bulk loader and
the incremental sync alike so the two cannot disagree about a value's shape.
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero a70977adc6 Add graph provenance, column naming and two transforms
A projected graph is a cache of one file at one revision, built by one
schema, and nothing in it said so. `GraphMeta` records the file, the
revision, the schema version and the producer, and is written last, so
its presence also marks the build complete and its contents say whether
a cached database is still worth opening.

- `graph/meta.clj`: the `GraphMeta` table and its writer.
- `graph/schema/contract.clj`: one place that maps a Penpot key to its
  graph column. The rule is snake_case of the key; every exception, be
  it a rename, a drop or a type override, is recorded there with its
  reason, so a divergence is a diff to review rather than a silent
  rename.
- `graph/project/document.clj`: `page-id` and the inherited
  `component-id` are written during the tree walk, which already knows
  both, rather than by a post-ingest statement. `graph/sync.clj` does
  the same on the incremental path, so a live-synced graph matches a
  rebuild.
- `graph/project/transforms.clj`: a registry, so adding a derived-link
  pass is one entry. Adds `RefersTo` (from `shape-ref`) and
  `FillsSwapSlot` (from `swap-slot-*` entries in `touched`, then
  stripped as `ctk/normal-touched-groups` does).
- `graph/debug.clj`, `graph/stats.clj`: enumerate relationship tables
  from the catalog instead of naming them, so the console's graph view
  and the ingest counts pick up new edge types without being told.
- `graph/debug.clj`, `http/debug.clj`: `graph-export` gains
  `source=session`, which snapshots the live in-memory console graph
  through EXPORT/IMPORT DATABASE. Live sync moves that graph away from a
  fresh projection, and taking it away to query elsewhere is the point
  of asking for it.

AI-assisted-by: mixed models
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 8f32c6af33 📎 Pin the graph console's G6 bundle to an exact version
The console loaded `@antv/g6@5` from jsDelivr, a floating major range,
so the JavaScript served into the page could change without a Penpot
release. Pin it to 5.1.1, the version the range resolves to today.

Where the dependency finally belongs is an open question for review:
vendored into `backend/resources`, declared in `frontend/package.json`
if the console moves out of `/dbg`, or left on the CDN. Pinning removes
the floating-code problem without pre-empting that decision.

AI-assisted-by: mixed models
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero d71ce58be5 ⬆️ Take Ladybug 0.19.1
`com.ladybugdb/lbug` moves from 0.18.0 to 0.19.1, the current release on
Maven Central. The engine fixes a SIGSEGV on an unwrapped parameter and
moves parameter coercion out of JNI, so shipping 0.18.0 would land a
native library into `develop` with a known crash already fixed upstream.

Nothing else changes. This branch has no `app.graph.arrow`, so the
top-level Arrow field-name backticking that 0.19.x retires does not
exist here and there is no workaround to remove alongside the bump.

AI-assisted-by: mixed models
2026-08-17 22:31:37 +02:00
Álvaro Tejero-Cantero fae5fcbb85 Put the graph subsystem behind a flag, off by default (#11075)
`app.graph.ladybug` imports `com.ladybugdb.*` at namespace load. Two
namespaces reach the subsystem and both required it at the top level:
`app.http.debug`, which registers the `/dbg` routes, and
`app.srepl.main`, which loads with the REPL server. Every backend built
from this branch therefore linked the Ladybug native library into the
JVM at boot, whether or not a graph was ever used.

Add a `:graph` flag to `varia`, deliberately absent from `default` so
that a released Penpot ships with the subsystem off. Both require sites
now resolve `app.graph.*` at call time, so with the flag off no
`com.ladybugdb` class is loaded. The nine `/dbg` graph routes are
registered only when the flag is on, and 404 otherwise. The `/dbg` admin
gate is untouched: the flag decides which routes exist, not who may
reach them. When the flag is on, route init requires the subsystem
eagerly, so a missing or unusable native library fails the boot rather
than the first console request.

No tracked file turns the flag on. `backend/scripts/_env` leaves it out,
so a devenv boots with the subsystem off exactly as a released build
does, and `docker/images/docker-compose.yaml`, the self-hosting
distribution, is untouched. Whoever works on the graph turns it on for
one checkout through the gitignored `backend/scripts/_env.local`, which
every backend and exporter dev script sources right after `_env`.

Verified with `-verbose:class` over a boot's namespace load plus
`ig/init-key ::routes`: 9 `com.ladybugdb` classes before this change
with no flag set, 0 after it with the flag off, 9 with `enable-graph`.
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero c32013c4c3 🐛 Use app.system/system in the graph ingest helper
develop renamed app.main/system to app.system/system and dropped the
app.main require while this branch was away. Rebasing replays the old
call, so clj-kondo reports an unresolved namespace and the ns will not
load.
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 7b89de6797 💄 One row per operation in the Live changes table
Columns revn | op | id: the revn repeats across a batch, the op wears the canvas diff colors (shape/attrs detail on hover), and the id column shows the uuid last group with the full uuid on hover, or N/A for ops without a subject id (e.g. mov-objects).

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero c2672d086a 🔥 Remove the edge-bundling plugin
Bundled edges render unsmooth and ugly on this build; the gating constant goes with it.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero cfeaf50668 🐛 Guard renders against heavy graphs; add ?safe escape hatch
A heavy file could freeze the tab on load-and-render despite the animation gate: the render guard counted nodes only, and the edge-bundling plugin is iteration-heavy in edges. Guard now also trips on edges (8000), edge bundling only activates at <= 300 edges, and /dbg/graph?safe disables auto-render entirely (counts + "Render anyway"), so a page that hung can always be re-entered with the session intact.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 54eb91e509 💄 Session breadcrumb, changelog colors, spacing
File line becomes team › project › file (clickable) with the resident-memory figure beside it (moved up from the graph-size line; breadcrumb resolves from the files-tree payload, so files outside the profiles teams show plain). add-obj/del-obj in Live changes wear the canvas diff colors. Paragraph margins tightened above Feed; left column 330→350 px.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero be6d3cd877 Enable the edge-bundling plugin
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero ef189acf71 Legend entries toggle node-table visibility
Clicking a legend entry hides/shows that table across the view (struck-through while hidden, kept listed for re-enabling; pure client-side id filter through filteredGraphData, edges drop with their endpoints, ghosts respect it). Also: setting fold >= depth above 0 now switches foldable containers on — a positive depth was silently inert without combos.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 7df4de2d10 💄 Console control-bar and session-panel rework
Left column narrowed 440->330 px (uuid input flexes). Control bar reordered: layout first, then animate and fade (narrow inputs), then the fold set; "fold containers" renamed "foldable containers" (on = foldable, not folded). Load becomes Reload once a session exists (same operation as the removed Full-reload button — load-session! on the current id; tooltip explains the fallback role) with Unload beside it. Session panel: revisions on one line ("ingested at N · graph now M", hover explains the difference), duplicate uuid after the file name dropped. Tried and rejected: fishbone (no positions on graph data) and compact-box (G6 tree layouts walk parent->child, IsChildOf points child->parent).

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero a86d1161a4 Report actual graph memory from the buffer manager
graph-data gains bm-bytes (CALL bm_info() -> [mem_limit mem_usage], nil-safe, under the session lock); the session panel shows it as MiB behind the node/edge counts — real resident memory replacing the removed estimate.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero ff8872eb98 💄 Console UI polish round
Merge the load form and files tree into one "Load graph from Penpot" box (tree first, uuid + Load/Unload in a row); Loaded session carries HH:MM in its legend; the Live changes box stays hidden until the first change arrives; query fieldset reads "Query graph (LadybugDB Cypher)" with the link covering both terms. Drop the hover tooltips (distracting, useless zoomed out) and the resident-size estimate (per-table counts stay on hover); every toggle gets a "When set/checked ..." title. Depth fold: 0 now expands every container (no more hunting for max depth). Node inspector: two-column flow, structured or long values folded behind the file-tree disclosure triangle.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero b9d087a323 🐛 Restore fold-containers as the combo master gate
Since fold-unchanged and depth folding arrived, withCombos ORed them in, so unchecking "fold containers" could no longer remove the combo boxes. The checkbox is the gate again; the derived fold rules are dormant without it.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero c6c2fd0e8e Add overview mode: fold containers at or beyond a depth
"fold >= depth" number input (root = 0, empty = off, localStorage): every combo whose container sits at that IsChildOf depth or deeper collapses, giving a top-of-file overview (e.g. 2 folds the containers hanging from a Page). Composes with fold-unchanged — depth folds first, changed ancestor paths are then drilled open. Derived fold state overrides manual folds while active.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 18db1debf8 Highlight clicked node neighborhood in graph console
click-select behavior with degree 1: the clicked element keeps a black ring, direct neighbors stay full-strength, everything else dims to 0.2 opacity (inactive state); clicking empty canvas clears. Works on edges too (selects both endpoints) and composes with the node inspector on the same click.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero e6dcd1af99 💄 Prune graph console layout roster and tune overlap
Remove grid, random, force, fruchterman, force-atlas2 (nothing over the kept set) and mds (stress layout degenerates to spokes on tree distances, no collision term to tune). Parameterize the keepers against node overlap — concentric/radial get preventOverlap+nodeSize, d3-force a collide radius — and shrink node labels to 7 px on those layouts (DENSE_LABEL_LAYOUTS), verified against variants_simple (72 nodes).

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 65d8e21afd Add PNG export and hover tooltips to graph console
Toolbar gains an export item: graph.toDataURL({mode: "overall"}) downloads the whole laid-out graph as graph-<revn>.png — page-chrome-free captures, also the fast path for agents debugging the console. A hover tooltip (table, label, id) backs the reduced/absent labels on dense layouts.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero a0033cbf20 💄 Polish graph console session panel and edge labels
Loaded-session fieldset: graph size gains a resident-memory estimate (fit to graph_sizes.md: ~1.1 MiB floor + ~5.4 KiB/node) with per-table counts on hover, replacing the load-time Projection stats; loaded-at compacts to local HH:MM with the full instant on hover. Edge rel labels drop to 7 px and lose the dashed stroke — the text label alone carries rel identity.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Alejandro Alonso 3cf6e20ba0 Sync Component library changes into the Ladybug graph 2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero fc354a9334 💄 Graph console QoL round
"Show result in graph view" moves into an actions bar above the results table; results scroll inside a 45vh container (client and server render paths); the Loaded-session fieldset gains a live "Graph size" line that stays fresh through skipped repaints; IsInstanceOf mid-edge label becomes the spelled-out rel name (∈ read as membership, not derivation) with the legend falling back to the dash-arrow for long syms.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero ff02ae709a Add node inspector panel to graph console
Clicking a node fetches its full attribute row (MATCH (n:`Table` {id: uuid(...)}) RETURN n.*) through the query endpoint and renders non-null attrs into a panel under the canvas (count of empty attrs noted). Panel over tooltip: projected tables carry ~80 columns, and the panel persists for reading without obstructing the graph. Table/id are validated before Cypher interpolation; the listener is re-attached on every instance recreation.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 194f21d68d Label edge rels with compact unicode symbols
Dash variants alone cannot carry the growing rel roster: EDGE_STYLES entries gain a sym rendered as a small mid-edge label with a white backing (IsInstanceOf = "∈"; IsChildOf stays unlabeled as the background structure), and the legend shows the symbol. Convention from the abacus viewer EDGE_SYM dict.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero f51e9d3bda 🐛 Expand folded combos that gain changed elements
setData merges datum props by id on a live G6 instance, so omitting style.collapsed retained a previous true: with "fold unchanged" on, a change inside a folded combo pulsed but never expanded it. Write the boolean explicitly both ways.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 3fc6c7df64 💄 Reserve chroma for changes in graph console diff mode
Monochrome entity scheme: all node tables share one slate hue, lightness separates within-glyph siblings (validated, worst pair dE 17.5), SVGRaw becomes the hollow hexagon, both rels go grey with dash as the only separator. Diff marks now own all color: thick green/crimson stroke ring (dashed for removals) plus a larger, subtler halo; the legend gains +/- entries while marks are live. Two additions to guide the eye: a brief DOM-overlay pulse on age-0 elements (independent of the G6 animation gate) and a "fold unchanged" toggle that collapses every combo not on an ancestor path of a changed element.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 3203961a2f Add graph diff marks with step fade to graph console
Each display-changing refetch is a step: added nodes/edges get a green halo, removed ones stay as ghosts with a dashed crimson halo (nodes, fading opacity) or thicker crimson stroke (edges), re-entering layout and combos through their ghost IsChildOf edges. Marks fade linearly and drop after N steps; N is the new "fade" number input (localStorage, 0 = off). Dash + fade carry the added/removed distinction under red-green CVD (#40c057/#c2255c, deutan dE 17.4); diff is vs the previous display step, not arbitrary revisions.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 7aa8e4c19c Style Component nodes and IsInstanceOf edges in graph console
Slice-3 export sends edges with a rel field. Derive tree ranking, combo derivation and fold-ability from IsChildOf only; draw other rels as overlay edges with per-rel styles (EDGE_STYLES: IsInstanceOf violet dashed, matching the new Component diamond in NODE_STYLES). Legend now lists only displayed node tables and rels, re-rendered per redraw; help text trimmed to essentials.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Alejandro Alonso 65d8402953 🐛 Fix memory leak 2026-08-17 22:31:37 +02:00
Alejandro Alonso 7ba6cc7700 Add Component nodes and IsInstanceOf edges 2026-08-17 22:31:37 +02:00
Alejandro Alonso 005d4a83d9 📎 Fix linter issues 2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 07fefd101f Make the default query self-explanatory; link the Cypher docs
The default query is now multi-line with // comments that explain the filter_* column convention in place (Kuzu accepts comments and blank lines mid-statement; verified against an in-memory database through the console query path). The query fieldset is retitled 'LadybugDB Cypher' with the Cypher word linking to https://docs.ladybugdb.com/cypher/.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 5b9b53ce01 🐛 Fix runaway graph panel growth and blank canvas; drop Expand button
Root cause of 'graph flashes on load then disappears' plus unbounded horizontal growth of the graph panel: fieldsets default to min-inline-size: min-content, so #graph-view-panel sized to its content, and the new ResizeObserver->setSize path closed a feedback loop (setSize -> slightly wider G6 canvas -> wider fieldset -> wider .dashboard flex column -> observer fires) that grew the page ~10px per frame and wiped the painted canvas on every step. Fix severs the feedback path: #graph-view-panel gets min-inline-size: 0, #graph-canvas gets overflow: hidden, and the page section gets flex: 1 1 0 with min-width: 0 so column widths are viewport-driven, never content-driven. This also fixes the original narrow-window scrollbars defect for real. The observer stays (guarded by a current-size comparison) because G6's autoResize is inert on this UMD build (verified: window resizes left the canvas size untouched); the inert autoResize flag is dropped. Legend items now join with spaces so the nowrap spans can wrap between entries.

Also removes the header Expand button - the toolbar's expand/exit icons cover it, Esc still restores.

Verified against the running devenv with a logged-in profile and variants_simple loaded: graph renders and persists, widths stable over multiple seconds at 1400px and 1000px viewports with no horizontal overflow, canvas follows both window shrink and grow, toolbar expand gives a full-page canvas and Esc restores.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero eeb7e2481e Add graph toolbar, animate toggle, filter columns, repaint skip
Graph view gains an on-canvas G6 toolbar (auto-fit, expand, restore - the fullscreen icons drive the existing in-page expand), an 'animate' checkbox that disables animation unconditionally when off (persisted, adaptive <=100-node rule applies only when on), and a ResizeObserver on the canvas so the panel follows window/flex resizes without touching the user's viewport. Preset tree positions are now only injected for the built-in tree layout, removing the tree-then-layout flash on animated re-renders under G6 layouts. Refetches skip the repaint when the display projection (nodes, edges, truncated) is byte-identical, so attribute-only change bursts no longer repaint.

Console: default query returns s/t name+label over all edges plus filter_src_id/filter_tgt_id columns; filter_* columns are hidden from the results table (client and server render) but still feed the 'Show result in graph view' id harvest, keeping the table legible while the graph filter stays available. The query text persists in localStorage across page reloads (restored only over the default, never over a server-rendered query). Legend shows colored Unicode glyphs matching node shapes instead of squares with textual annotations. Load/Unload buttons share one row (HTML5 form attribute), and the loaded file name links to the Penpot workspace via the legacy /#/workspace/<project-id>/<file-id> route resolved client-side from the files-tree payload.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero bb5be56b6c Add query-result subgraph, ws auto-reconnect, adaptive animation
The Cypher result pane now offers 'Show result in graph view': any UUID found in any result cell selects the matching nodes in the cached export and the view renders the induced subgraph (edges kept when both endpoints match); 'Show full graph' resets. No graph reconstruction from the query result is needed.

The notifications websocket reconnects automatically (3 s retry) and resubscribes + refetches on reopen, so backend restarts no longer permanently kill the live feed; a lost session now reports 'no graph session (backend restarted?) - reload a file' instead of a bare 404.

Animation is size-adaptive: graphs (or filtered subgraphs) up to 100 nodes render animated for didactics, larger ones stay animation-free; crossing the threshold recreates the instance like a layout switch.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero bbe23d3be6 Add layout dropdown to graph view
Adds a layout <select> next to the fold toggle, populated from the LAYOUTS map in the template: 'tree' (the O(n) preset layout, default) plus 13 G6 layouts (antv-dagre, dagre, circular, concentric, radial, grid, force, d3-force, force-atlas2, fruchterman, mds, combo-combined, random), all smoke-tested against combo data on this UMD build. Layout and fold toggle are independent; switching layouts recreates the graph instance (cheap with animation off); both choices persist in localStorage. antv-dagre stays available for when non-tree edges arrive.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero e29e79ed90 Fix graph view freeze on large files; add fold toggle and root rule
Root cause of the tab freeze on ~1700-node files was G6's default entrance animation: measured 1700 nodes at >2 min animated vs 1.5 s with animation: false. Secondary cost was antv-dagre (~7 s at that size); since IsChildOf is a tree, an O(n) tidy layout (depth = rank, post-order leaf slots, parents centered) computed client-side replaces it and renders the same file in ~1.4 s. A guard skips auto-render above 4000 nodes with an explicit Render-anyway button, so opening the console with a huge session loaded stays responsive.

Folding is now switchable ('fold containers' checkbox, persisted in localStorage) and generalized: any node with children folds except the IsChildOf root of the loaded graph, so Documents (and later Projects/Teams) fold automatically once they gain a parent node.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 6e1a319135 Fold containers as collapsible combos in graph view
Non-empty containers (Page, Frame, Group, Boolean, SVGRaw) render as nested G6 rect combos holding their own node plus direct children; Document stays a plain node. Double-click folds/expands (collapse-expand behavior); collapsed combos show a member count and re-route child edges. Fold state is read back from getComboData and re-marked on every refetch, so it survives live redraws. Layout gains sortByCombo to keep same-rank nodes grouped by box.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 2ef9c6aa4a ♻️ Replace fullscreen with in-page expand for graph view
Fullscreen API took over the whole output and broke window-manager splits (and is denied in some environments). The Expand button now toggles a fixed-position overlay covering the page while keeping browser chrome; Esc restores. Column positioning moved from inline style to the stylesheet so the expanded class can override it.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero e2a56bb05c Split graph console in two columns; add file tree and fullscreen
Graph view moves to its own sticky right column (overrides .widget max-width). New /dbg/actions/graph-files endpoint lists teams -> projects -> files for the profile; the console renders it as a collapsible tree where clicking a file loads it. Maximize button fullscreens the graph panel and resizes G6 on fullscreenchange.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 4c4b1e5f59 🐛 Fix list-column CSV ingest and serialize graph session access
COPY failed on any file with container shapes: list-typed DDL columns (shapes UUID[], points STRING[], strokes JSON[], ...) were JSON-encoded in staging CSVs, which Ladybug's list parser rejects. Write Kuzu list literals instead, typed per column. Also: value->clj no longer crashes on LIST/STRUCT values (binding lacks value_get_value support; fall back to string), and the debug session Connection is now guarded by a per-session lock — it was shared unsynchronized between the msgbus sync loop and HTTP query/export handlers, and one lost DETACH DELETE was observed under concurrent refetch load.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Álvaro Tejero Cantero 9b86a4bb01 Add G6 graph view to debug graph console
POC per work/g6/plan.md. New /dbg/actions/graph-data exports the in-memory Ladybug session as plain JSON (per-table node queries + multi-table IsChildOf match, row cap 100k with truncation flag). Console page renders it with AntV G6 v5 (jsDelivr CDN, antv-dagre BT layout, color+glyph per node table, validated palette) and refetches debounced on live :file-change messages.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-17 22:31:37 +02:00
Alejandro Alonso 5aa6d50344 ♻️ Derive graph node schema from Malli registry 2026-08-17 22:31:37 +02:00
Alejandro Alonso 4d7bb51984 🐛 Fix batch delete sync and keep graph console feed alive 2026-08-17 22:31:37 +02:00
Alejandro Alonso 85fc8ae664 Handle mov-objects in debug graph sync 2026-08-17 22:31:37 +02:00
Alejandro Alonso 4d088ef40d Incrementally sync debug graph from Penpot file changes 2026-08-17 22:31:37 +02:00
Alejandro Alonso 0eef304c59 Add live file-change feed to debug graph console 2026-08-17 22:31:37 +02:00
Alejandro Alonso 1b1e3004ea Add debug graph console for in-memory Cypher queries 2026-08-17 22:31:37 +02:00
Alejandro Alonso ef92b91c8e Add Ladybug graph export to debug UI 2026-08-17 22:31:37 +02:00
Alejandro Alonso f6d933921d 🐛 Fix graph COPY ingest for multiline text names 2026-08-17 22:31:37 +02:00
Alejandro Alonso 0313883889 Load graph ingest via Ladybug COPY bulk import 2026-08-17 22:31:37 +02:00
Alejandro Alonso b623ab0e17 Project nested shapes recursively into the graph 2026-08-17 22:31:37 +02:00
Alejandro Alonso 50c648e940 Validate graph ingest projections with Malli 2026-08-17 22:31:37 +02:00
Alejandro Alonso dd21181967 ♻️ Share Ladybug connection across ingest and stats 2026-08-17 22:31:37 +02:00
Alejandro Alonso e19711333b Use embedded Ladybug Java API instead of CLI 2026-08-17 22:31:37 +02:00
Alejandro Alonso cfd25138bb Add Penpot-to-Ladybug graph ingest vertical slice 2026-08-17 22:31:37 +02:00
Alejandro Alonso 9a9d6c35f7 🎉 Basic lbug connection for ingestion 2026-08-17 22:31:37 +02:00
Andrey Antukh fb9f92ae6a Merge remote-tracking branch 'origin/staging' into develop 2026-08-17 13:52:46 +02:00
David Barragán Merino 59ef07633a 🔧 Align MCP workflow name with the rest of CI workflows
The MCP workflow was named "MCP CI" while every other tests-*.yml
workflow uses the "CI: <Component>" pattern. Rename it to "CI: MCP"
for consistency in the GitHub Actions listing.
2026-08-14 20:15:23 +02:00
Alejandro Alonso ba235f46c9 Merge remote-tracking branch 'origin/staging' into develop 2026-08-14 13:50:48 +02:00
Belén Albeza e56c801820 🐛 Fix selrect collapsing after undo (v3) (#11239) 2026-08-14 13:36:40 +02:00
Alejandro Alonso 6269fa7a3f Merge remote-tracking branch 'origin/staging' into develop 2026-08-14 10:37:43 +02:00
Alejandro Alonso 136052c15e Merge remote-tracking branch 'origin/staging' into develop 2026-08-13 14:28:21 +02:00
Alejandro Alonso cb57fd9dfa Skip save_layer for plain image fills (#11230)
Avoid an offscreen buffer per Fill::Image during tile walks: only use
save_layer when a shape image filter is present; axis-aligned rects and
frames without corner radii also skip the redundant container clip.
2026-08-13 12:12:43 +02:00
Belén Albeza be83656d55 🎉 Add caret style changes (text editor v3) (#11171)
* 🐛 Fix editor v3 quitting when changing typography options

* 🎉 Apply text styles to collapsed caret

* 🐛 Fix not persisting the new selrect

* 🐛 Fix selrect not being recomputed on caret style changes

* 🐛 Fix quitting the editor when changing typography on empty texts
2026-08-13 07:23:15 +02:00
Alejandro Alonso 1c14c854ae Merge remote-tracking branch 'origin/staging' into develop 2026-08-13 07:11:43 +02:00
David Barragán Merino af1537d071 Shard integration e2e tests across four parallel jobs
Split the integration suite into four shards running two Playwright
workers each. Median wall time for the job drops from ~40 min to an
expected ~15 min; the build job is unchanged at ~4 min.

Shard reports are merged into a single HTML report, and the merged
run is summarised in the job step summary: totals, failed specs and
flaky specs ranked by retry count.

Chromium is installed into a shared volume so shards do not
re-download it. `workflow_dispatch` allows running the suite manually
against an arbitrary ref, with configurable shard layout and workers.

PRs targeting `staging` keep running serially while the current
release stabilizes. The exception is marked TEMPORARY and removed in
a follow-up.
2026-08-12 18:43:42 +02:00
María Valderrama 3b9e0782e4 🐛 Fix sso error message (#11225) 2026-08-12 17:06:02 +02:00
Alejandro Alonso 201b51e8c5 Merge remote-tracking branch 'origin/staging' into develop 2026-08-12 15:11:31 +02:00
Alejandro Alonso be9df28b00 Merge remote-tracking branch 'origin/staging' into develop 2026-08-12 07:30:05 +02:00
Elena Torró 868340dfba 🐛 Fix text layer bounds clipping glyph (#11141) 2026-08-12 07:10:34 +02:00
David Barragán Merino 9f17aa6216 🔧 Report flaky e2e tests in integration workflow
Enable Playwright's JSON reporter alongside `list` and publish a
summary of flaky tests to the job step summary. The JSON report is
kept as an artifact for 30 days so flakiness rates can be aggregated
over time.

CI already runs with `retries: 2`, so unstable tests have been passing
silently on retry. This only surfaces what the suite already absorbs;
no test behaviour changes.

The reporter in `frontend/scripts/test-e2e` becomes overridable via
`PLAYWRIGHT_REPORTER` so the local developer default stays untouched.
2026-08-11 19:50:17 +02:00
Alejandro Alonso 290b14167a 🔧 Allow forcing render-wasm DPR via ?dpr= query param (#11211)
Makes HiDPI repro possible without hardcoding get-dpr or relying on the
real devicePixelRatio (e.g. ?dpr=2).
2026-08-11 17:11:06 +02:00
Eva Marco 044d7ac15f ♻️ Update colorpicker scss file (#11208) 2026-08-11 14:29:29 +02:00
Belén Albeza 4a1d6e6d57 🐛 Fix creating minimal path shapes (#11210) 2026-08-11 13:10:44 +02:00
Alejandro Alonso 0de47302a6 Merge remote-tracking branch 'origin/staging' into develop 2026-08-11 12:47:36 +02:00
Filip SajdakandClaude Opus 5 fcd33340b3 🐛 Use a single translation key for the Mixed values label (#11151)
The design sidebar named the same "mixed values" concept with two
different translation keys. Most sections use settings.multiple, while
the blur options and the design system numeric input used
labels.mixed-values.

Both read "Mixed" in English, so the split is invisible in the default
locale, but labels.mixed-values has no translation at all in 16 locales
and a different wording in 8 more. Where it is missing the string falls
back to the default language, so those controls rendered the English
word next to sections showing the localized one; where both exist, a
single sidebar named the same concept two ways (fr "Divers" against
"Melange", ru "Smeshanyy" against "Smeshat").

Point the two outliers at settings.multiple, the key the rest of the
sidebar already uses and the one translated in every locale that ships
a translation for it.

Fixes #11148.

Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 10:24:23 +02:00
Álvaro Tejero-CanteroandAndrey Antukh b5bec4f983 🐛 Declare new shape attributes in schemas to match stored files (#11125)
* 🐛 Declare the shape attributes stored files carry

`schema:shape-attrs` is the shape model as *declared*, and it has fallen
behind the `Shape` record. Three record fields are absent from it:
`rotation`, `flip-x` and `flip-y` are therefore present on every shape
that exists and declared nowhere. `rotation` is already named twice in
this namespace, in `allowed-shape-attrs`, and once in
`app.common.types.shape.attrs/editable-attrs`, so the schema is
demonstrably the odd one out rather than the data being unusual.

Nothing complains, because the maps are open: an undeclared key
validates fine. What breaks is everything that reads the model *from the
schema* rather than from a live value, such as the generative tests'
shape generator, the generated OpenAPI surface, and any consumer
reflecting over `schema:shape-attrs`.

Whether an entry is optional, nilable, or both is decided by the record
rather than by taste. `app.common.record/defrecord` cannot remove a base
field: its `without` assocs nil and its `containsKey` answers true
whatever the field holds, on both platforms. So a `Shape` base field is
always present, and nil is how that field says "unset". Every other key
lives in the `$extmap`, disappears on dissoc, and is dropped by
`setup-shape` when a caller passes nil. Base fields are therefore
nilable, and the rest are optional.

Declared here, measured over a 305-shape corpus:

- `rotation`, `flip-x` and `flip-y`, record fields present on every
  shape, nilable for the reason above: `make-minimal-shape` gives the
  two flip fields no default, so they are nil on all 305. Optional as
  well, unlike the geometry below, because `schema:shape-generic-attrs`
  has a second job: `check-shape-generic-attrs` validates partial update
  payloads with it, such as the `{:blocked true}` that
  `app.main.data.workspace/update-shape` passes, and a required key here
  would reject every such payload.
- `hide-in-viewer`, moved out of `schema:frame-attrs`, because circles,
  rects and texts carry it too, 197 shapes.
- `svg-attrs`, `svg-defs`, `svg-transform` and `svg-viewbox`, the SVG
  provenance an import leaves behind, 101 shapes and 63 for the
  transform. Typed `:map` rather than more precisely on purpose: legacy
  files hold `svg-transform` as a plain `{:a … :f}` map rather than a
  `::gmt/matrix` record, and `svg-viewbox` as either a `::grc/rect`
  record or a plain map, so a tighter schema would reject files that are
  otherwise valid.
- `use-for-thumbnail` on frames. The model has long had it:
  `app.common.files.migrations` renames `:use-for-thumbnail?` to it and
  `app.common.logic.libraries` reads it. This schema had not declared
  it.
- `rx` and `ry` on rects and circles, the legacy radii SVG import parses
  off the element and migration 0003 assocs as `0`. Superseded by `r1`
  to `r4`, but stored files carry them.
- `content` on svg-raw. `shapes-builder/create-raw-svg` sets it and
  `allowed-svg-attrs` names it. Typed `[:or :map :string]`, because a
  bare text node arrives as the string itself: `<text>hi</text>` becomes
  one svg-raw for the element and another for `"hi"`, and
  `shapes-builder/parse-svg-element` carries a FIXME about exactly that.

`schema:nilable-geom-attrs` is new, for bool and path. Those two are the
only shape types whose geometry can be nil: `make-minimal-shape` gives
`x`, `y`, `width` and `height` a default for every other type and skips
those two, whose extent their content and `selrect` imply instead. The
four keys stay required, as they already are in the other seven
branches, and only the nil is new.

**Do not make the analogous change to `ctf/schema:file`.** That map
carries `:backend`, `:comment-thread-seqn` and `:ignore-sync-until`,
none of which the schema declares, and declaring them breaks saving:
`app.binfile.common/update-file!` derives its UPDATE column list from a
file map's keys, and the `file` table has no `backend` column, it being
synthesized on read. Measured at 185 failures, mostly `rpc-file-test`.
Whether a schema serving as both read description and write contract is
itself a defect is a real design question, and a separate one. The
`check-shape-generic-attrs` case above is a second instance of it.

Adding entries changes what `shape-generator` produces, so generative
tests begin exercising code paths with these attributes present. That is
where a problem would surface. With this applied the common suite is
1142 tests and 24702 assertions on the Clojure side, 992 tests and 24017
assertions on the ClojureScript side, no failures on either.

AI-assisted-by: mixed models

*  Align shape generator with declared schema and add key-presence test

shape-generator now selects geometry attrs per-type: nilable-geom-attrs
for bool/path, shape-geom-attrs for everything else, and always merges
them. This removes the dead attrs2 generation for bool/path and the
implicit dependency on create-shape adding nil defaults for missing
base record fields.

The new shape-generator-key-presence test asserts that generated shapes
carry the required keys: rotation, flip-x, flip-y on all shapes and x,
y, width, height on bool/path, even when nilable.

AI-assisted-by: longcat-2.0-free

* 🐛 Sample 200 shapes in the key-presence test, not 10

`sg/sample` hands its options to `malli.generator/sample`, which reads
`:size`. `:num` is test.check's option. It is correct for the
`smt/check!` call directly above, where it came from, but `sg/sample`
ignores it and falls back to its default of 10.

Ten samples leave the bool and path assertions vacuous about one run in
fourteen. Simulated over 200 draws of 10, 14 contained no bool and no
path at all, and the median draw held 2. Those four assertions defend
exactly the keys this branch made required, so a run that skips them
silently is the one case worth not missing.

The assertion count shows the arithmetic. The test contributed 42 with
`:num`, which is 10 shapes times 3 keys plus 3 bool-or-path shapes times
4 keys, and contributes 756 with `:size`. The common suite goes from
1143 tests and 24744 assertions to 1143 tests and 25458 assertions, no
failures either way.

AI-assisted-by: mixed models

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-08-07 14:20:24 +02:00
Elena Torró 30bc2a4bc3 🔧 Add FF to enable wasm export at team level (#11130) 2026-08-07 12:36:52 +02:00
Alejandro Alonso 43b12bc4b9 Soft-drain GPU mid-walk on progressive Partials (#11127)
Release packs far more cheap Current draws (e.g. fills_none paths)
into one Partial than debug; a single end-of-Partial
flush_and_submit then stalls the browser. Soft-flush every N walker
nodes (and on Partial yield) keeps ops buffers bounded while Full
still submits via present_frame.
2026-08-07 09:45:39 +02:00
Andrey Antukh e1c51442cd Merge remote-tracking branch 'origin/staging' into develop 2026-08-07 09:10:25 +02:00
Andrey Antukh 88697794ce Merge remote-tracking branch 'origin/staging' into develop 2026-08-06 20:55:34 +02:00
Elena Torró 38b990ef90 🔧 Add exporter headless backend (#10875)
*  Add headless wasm render backend to the exporter

* ♻️ Move render-wasm bridge to common and split wasm builds

* 🔧 Upload builtin font variants in the wasm exporter

* ♻️ Move shared font and resources utils out of render_wasm

*  Fetch only the exported roots in the wasm exporter

*  Bound save_layer rects in the vector export path
2026-08-06 16:13:06 +02:00
Alejandro Alonso a76401596e Skip imperceptible shadows and simplify low-scale strokes (#11102)
*  Skip drop shadows that are imperceptible at current scale

Filter drop shadows by on-screen footprint (stricter for recursive
shapes) so overview HQ avoids expensive blur passes that barely show.

*  Simplify Path and Bool strokes at low scale

At overview zooms, Inner/Outer strokes fall back to Center and
dash/dotted styles become solid when the pattern is subpixel.
Strokes are never skipped so stroke-only icons stay visible.

*  Drain GPU work on partial render frames

Partial frames only flushed the Backbuffer, so tile GPU commands
queued until present_frame's flush_and_submit and stalled the
browser on large files. Submit the context each partial frame
without presenting Target or re-composing the tile atlas.

*  Prefer direct painting when effects are imperceptible

Skip the Fills/Strokes layered path when drop/inner shadows would
not paint at the current scale, and allow stroke-only shapes
(fills_none) on the direct path. Apply the same footprint LOD to
inner-shadow painting.
2026-08-06 15:58:11 +02:00
Belén Albeza de8d8ca401 🐛 Fix serialization of constraints (#11108) 2026-08-06 15:49:04 +02:00
Álvaro Tejero-Cantero 314a2a245f 📚 Fix the devenv backend-flags instructions (#11077)
The section pointed at `docker/devenv/docker-compose.yaml`, which #9906
deleted when it split the devenv compose into `docker-compose.infra.yml`
and `docker-compose.main.yml`. The same page names both replacements in
its architecture section, so only this one was missed.

Setting PENPOT_FLAGS in the container environment would not have worked
anyway: `backend/scripts/_env` expands the inherited value before its own
list, so its flags win. Document the mechanism that does work, the
gitignored `backend/scripts/_env.local` that `start-dev` sources right
after `_env`, and the left-to-right last-wins rule that lets an override
switch off a flag `_env` enables.
2026-08-06 14:14:44 +02:00
Andrey Antukh 614d619173 Merge remote-tracking branch 'origin/staging' into develop 2026-08-06 13:31:29 +02:00
Belén Albeza 2392015c63 🐛 Fix microinteractions on text shape selrects for autowidth/autoheight (#11068) 2026-08-06 12:40:57 +02:00
Alejandro Alonso 11fc090bc4 Expand direct shape painting and skip empty drop-shadow blits (#11100)
* ♻️ Extract apply_clip_stack_to_surfaces helper

Share the layered-path clip loop so the Current-surface direct
path can reuse the same hard-clip stack without duplication.

*  Expand direct shape painting onto Current

Allow clip stacks, frames, non-identity transforms, and SrcOver
opacity on the Current-surface fast path; skip empty non-masked
groups. Avoids Fills/Strokes blits for common shapes.

*  Skip empty drop-shadow blits; warm DropShadows once

Early-out drop-shadow composite when a shape has no visible
shadows, and touch DropShadows→Current once per tile instead
of per shape to keep flush_and_submit cheap.
2026-08-06 12:31:49 +02:00
Elena Torró 10a2c19f92 🔧 Improve text editor selection and tab conversion (#11071) 2026-08-06 09:43:02 +02:00
Alejandro Alonso 4b413299c2 Clear dirty flags after tile surface reset (#11095)
Marking intermediate surfaces dirty after clearing them on tile
context switch made the first stack composite blit empty
Fills/Strokes/shadows into Current. Dirty means content to
composite, so clear the flags after the clear instead.
2026-08-06 09:09:09 +02:00
Andrey Antukh 31c9ab4701 Merge remote-tracking branch 'origin/staging' into develop 2026-08-06 09:05:48 +02:00
Alejandro Alonso 8b64b0f84f Fix progressive render budget when timestamp is stale (#11094)
Pass performance.now from finalize/debounce and re-anchor the WASM
budget if the stamp is 0 or already past max_blocking_time, so HQ
tiles are not yielded after a few nodes with almost no real work.
2026-08-06 09:00:45 +02:00
Alejandro Alonso 649f4bebef Merge remote-tracking branch 'origin/staging' into develop 2026-08-06 08:38:15 +02:00
Andrey Antukh 5b26913cd3 Merge remote-tracking branch 'origin/staging' into develop 2026-08-05 17:30:41 +02:00
Andrey Antukh 36e76da26c Revert "🐛 Fix text creating on draft.js (#11086)"
This reverts commit 6df045b194.
2026-08-05 17:30:36 +02:00
Alejandro Alonso 35bdcde183 Avoid per-tile image_snapshot when filling atlases (#11093)
Copy Current into DocAtlas and the tile atlas with Surface::draw
instead of image_snapshot_with_bounds, matching the interactive
path and removing a GPU sync stall on every completed tile.
2026-08-05 17:12:09 +02:00
Eva Marco 6df045b194 🐛 Fix text creating on draft.js (#11086) 2026-08-05 13:10:21 +02:00
Belén Albeza 1b26b69b25 🐛 Fix Firefox not inserting emoji from MacOS Character Viewer (#11072) 2026-08-05 12:53:20 +02:00
Andrey Antukh 6f2bfb617c Merge remote-tracking branch 'origin/staging' into develop 2026-08-05 10:16:06 +02:00
David Barragán Merino 34702fd46b 🐳 Remove the configuration of the admin-console from Nginx if it is not enabled 2026-08-04 20:32:17 +02:00
Filip SajdakandAndrey Antukh 648c8e2152 🐛 Keep svg-raw children as uuids on binfile import (#10837)
Importing a .penpot file left every svg-raw subtree broken: the parent's
:shapes vector came back holding plain strings instead of uuids, so the
child ids no longer resolved against the page objects map. The next
persisted change touching that page then failed referential integrity
validation with :child-not-found, surfaced to the client as an HTTP 400
:referential-integrity error, which in practice bricks the file.

An svg-raw shape can be a container: importing an SVG builds a tree of
svg-raw shapes, and cfh/group-like-shape? explicitly treats an svg-raw
with children as group-like. But schema:svg-raw-attrs was an empty map.
Frame, group and bool all declare :shapes as a vector of uuid; svg-raw
did not, so the JSON decoder used by binfile had no type information for
those ids and left them as strings.

Declare :shapes on schema:svg-raw-attrs, optional because a leaf svg-raw
shape has no children, so the child ids decode back to uuids.
Closes #10496.

Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-08-04 16:49:12 +02:00
7ae57a035f 🐛 Position overlays by frame selrect, not filter-inflated bounds (#10454)
calc-overlay-position measured the destination overlay frame with its full
object bounds (get-object-bounds) while measuring the relative-to frame with
its selrect. Object bounds include padding for shadows, blur, outer strokes
and overflowing children, so centered/right/bottom overlays were shifted by
half that extra padding when the overlay frame had such effects (the overlay
appeared offset, e.g. a bit to the left).

Use the destination frame selrect (the visible frame box) instead, which
matches the sibling helper calc-overlay-pos-initial and the viewer, which
reserves the bounds size and re-aligns the selrect separately. The now unused
geom.shapes.bounds require is removed.

Adds a regression test asserting calc-overlay-position returns the same
position with and without a bounds-inflating drop shadow on the destination
frame.

Fixes #9048

Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-08-04 16:42:55 +02:00
Elena Torró 14a6ea5c52 🔧 Support text style shortcuts (#11002) 2026-08-04 15:30:26 +02:00
Andrey Antukh ca29f734c7 Merge remote-tracking branch 'origin/staging' into develop 2026-08-04 15:08:55 +02:00
David Barragán Merino 0811b1cda6 🔧 Generate the Docker image for the admin console by creating a tag 2026-08-04 11:59:45 +02:00
Andrey Antukh 6e843faba3 Merge remote-tracking branch 'origin/staging' into develop 2026-08-03 18:36:27 +02:00
Belén Albeza c6c8a38544 🐛 Fix not being able to add multiple fills to text spans (v3) (#10988) 2026-08-03 17:11:59 +02:00
Elena Torró 0fed63eeb3 🐛 Fix text replacement on selection and text offsets (#10983)
* 🐛 Fix text not being replaced when there is a selection

* 🐛 Fix text editor offsets on transformed text
2026-08-03 15:22:49 +02:00
132 changed files with 19439 additions and 1037 deletions

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
def specs: [.. | objects | select(has("tests") and has("file"))];
def dur: [.tests[].results[]?.duration // 0] | add;
specs as $s
| ($s | map(select(any(.tests[]; .status == "unexpected")))) as $failed
| ($s | map(select(any(.tests[]; .status == "flaky")))) as $flaky
| ($s | map(select(any(.tests[]; .status == "skipped")))) as $skipped
| ($s | length) as $total
| ($s | map(dur) | add // 0 | . / 1000 | floor) as $cpu
| (if ($failed | length) > 0 then "❌"
elif ($flaky | length) > 0 then "⚠️"
else "✅" end) as $icon
| "## \($icon) Integration tests\n\n"
+ "| Total | Passed | Flaky | Failed | Skipped | Test time |\n"
+ "|---|---|---|---|---|---|\n"
+ "| \($total) | \($total - ($failed|length) - ($flaky|length) - ($skipped|length)) "
+ "| \($flaky|length) | \($failed|length) | \($skipped|length) | \($cpu / 60 | floor)m |\n"
+ (if ($failed | length) > 0 then
"\n### Failed\n\n"
+ ($failed | map("- `\(.file):\(.line)` — \(.title)") | join("\n")) + "\n"
else "" end)
+ (if ($flaky | length) > 0 then
"\n### Flaky (passed on retry)\n\n"
+ ($flaky
| map({ t: "`\(.file):\(.line)` — \(.title)",
r: ([.tests[].results[]? | select(.status == "failed")] | length) })
| sort_by(-.r)
| map("- \(.t) _(\(.r) \(if .r == 1 then "retry" else "retries" end))_")
| join("\n")) + "\n"
else "" end)
+ (if $total > 0 then
"\n<details><summary>Slowest specs</summary>\n\n"
+ ($s | map({ t: "`\(.file)` — \(.title)", d: (dur / 1000 | floor) })
| sort_by(-.d) | .[0:5]
| map("- \(.t) — \(.d)s") | join("\n"))
+ "\n\n</details>\n"
else "" end)
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
with:
gh_ref: "develop"
build-admin-console-docker:
build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
with:
gh_ref: "staging"
build-admin-console-docker:
build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
+12 -2
View File
@@ -20,10 +20,18 @@ jobs:
with:
gh_ref: ${{ github.ref_name }}
build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
gh_ref: ${{ github.ref_name }}
notify:
name: Notifications
runs-on: ubuntu-24.04
needs: build-docker
needs:
- build-docker
- build-docker-admin-console
steps:
- name: Notify Mattermost
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
@@ -37,7 +45,9 @@ jobs:
publish-final-tag:
if: ${{ !contains(github.ref_name, '-RC') && !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && contains(github.ref_name, '.') }}
needs: build-docker
needs:
- build-docker
- build-docker-admin-console
uses: ./.github/workflows/release.yml
secrets: inherit
with:
+160 -39
View File
@@ -5,11 +5,37 @@ defaults:
shell: bash
on:
workflow_dispatch:
inputs:
gh_ref:
description: 'Name of the branch or ref'
type: string
required: true
default: 'develop'
shards:
description: 'Shard layout (JSON array)'
type: choice
required: true
default: '[1, 2, 3, 4]'
options:
- '[1, 2, 3, 4]'
- '[1, 2, 3, 4, 5, 6]'
- '[1, 2]'
- '[1]'
workers:
description: 'Playwright workers per shard'
type: string
required: true
default: '2'
pull_request:
paths:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
- '.github/workflows/tests-integration.yml'
types:
- opened
@@ -25,9 +51,10 @@ on:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
- '.github/workflows/tests-integration.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.gh_ref || github.ref }}
cancel-in-progress: true
jobs:
@@ -35,15 +62,30 @@ jobs:
if: ${{ !github.event.pull_request.draft }}
name: "Build Integration Bundle"
runs-on: penpot-runner-02
timeout-minutes: 30
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
outputs:
bundle_key: ${{ steps.vars.outputs.bundle_key }}
steps:
# An empty `ref` makes checkout fall back to its default (the PR merge
# ref on pull_request, the pushed ref on push).
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.gh_ref }}
# The cache key must come from the SHA actually checked out: on a manual
# run `github.sha` points at the dispatching ref, not at `gh_ref`.
- name: Extract cache key
id: vars
run: |
echo "bundle_key=integration-bundle-$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Build Bundle
working-directory: ./frontend
@@ -53,72 +95,151 @@ jobs:
- name: Store Bundle Cache
uses: actions/cache@v5
with:
key: "integration-bundle-${{ github.sha }}"
key: ${{ steps.vars.outputs.bundle_key }}
path: frontend/resources/public
test-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests"
name: "Integration Tests (${{ matrix.shard }})"
runs-on: penpot-runner-02
timeout-minutes: ${{ github.base_ref == 'staging' && 60 || 25 }}
needs: build-integration
# TEMPORARY (release stabilization): PRs targeting `staging` run on a
# single serial shard, so new flakes cannot block the release work.
# Remove the `github.base_ref` branch below to restore full parallelism.
strategy:
fail-fast: false
matrix:
shard: ${{ fromJSON(inputs.shards || (github.base_ref == 'staging' && '[1]' || '[1, 2, 3, 4]')) }}
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
- /var/cache/github-runner/ms-playwright:/ms-playwright
env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
steps:
- name: Checkout Repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.gh_ref }}
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: ${{ needs.build-integration.outputs.bundle_key }}
path: frontend/resources/public
- name: Install deps
working-directory: ./frontend
run: |
corepack enable;
corepack install;
pnpm install --frozen-lockfile;
# No-op once the shared volume is warm; keeps the first run working.
- name: Install Playwright Chromium
working-directory: ./frontend
run: pnpm exec playwright install chromium
# `strategy.job-total` is the matrix size, so the shard denominator
# follows the `shards` input without being hardcoded.
- name: Run Tests
working-directory: ./frontend
env:
WORKERS: ${{ inputs.workers }}
BASE_REF: ${{ github.base_ref }}
run: |
# TEMPORARY (release stabilization): see the note on the matrix above.
if [ -z "$WORKERS" ]; then
if [ "$BASE_REF" = "staging" ]; then WORKERS=1; else WORKERS=2; fi
fi
echo "Running shard ${{ matrix.shard }}/${{ strategy.job-total }} with $WORKERS workers"
pnpm exec playwright test --project default \
--workers="$WORKERS" \
--shard=${{ matrix.shard }}/${{ strategy.job-total }} \
--reporter=blob
- name: Upload blob report
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-blob-report-${{ matrix.shard }}
path: frontend/blob-report/
overwrite: true
retention-days: 3
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result-${{ matrix.shard }}
path: frontend/test-results/
overwrite: true
if-no-files-found: ignore
retention-days: 3
merge-reports:
if: ${{ always() && !github.event.pull_request.draft && needs.test-integration.result != 'skipped' }}
name: "Merge Integration Reports"
runs-on: penpot-runner-02
timeout-minutes: 15
needs: test-integration
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
ref: ${{ inputs.gh_ref }}
- name: Run Tests
- name: Install deps
working-directory: ./frontend
run: |
corepack enable;
corepack install;
pnpm install --frozen-lockfile;
- name: Download blob reports
uses: actions/download-artifact@v7
with:
path: frontend/all-blob-reports
pattern: integration-blob-report-*
merge-multiple: true
- name: Merge into HTML report
working-directory: ./frontend
env:
PLAYWRIGHT_REPORTER: list,json
PLAYWRIGHT_JSON_OUTPUT_NAME: report.json
run: |
./scripts/test-e2e
pnpm exec playwright merge-reports \
--reporter=html,json,list ./all-blob-reports
- name: Flaky summary
- name: Test summary
if: always()
working-directory: ./frontend
run: |
if [ ! -f report.json ]; then
echo "No report.json produced (the run failed early)." >> "$GITHUB_STEP_SUMMARY"
echo "No report produced (all shards failed early)." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
jq -r -f ../.github/scripts/playwright-summary.jq report.json >> "$GITHUB_STEP_SUMMARY"
jq -r '
[ .. | objects
| select(has("tests") and has("file"))
| select(any(.tests[]; .status == "flaky"))
| "- `\(.file):\(.line)` — \(.title)"
] as $f
| "## Flaky tests: \($f | length)\n"
+ (if ($f | length) == 0 then "_none_" else ($f | join("\n")) end)
' report.json >> "$GITHUB_STEP_SUMMARY"
- name: Upload JSON report
- name: Upload HTML report
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-json-report
path: frontend/report.json
name: integration-html-report
path: frontend/playwright-report/
overwrite: true
retention-days: 30
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result
path: frontend/test-results/
overwrite: true
retention-days: 3
retention-days: 7
+2
View File
@@ -58,6 +58,8 @@ opencode.json
/docker/images/bundle*
/exporter/target
/exporter/.shadow-cljs
/exporter/resources/wasm/
/exporter/src/app/wasm/shared.js
/frontend/.storybook/preview-body.html
/frontend/.storybook/preview-head.html
/frontend/playwright-report/
+1 -1
View File
@@ -6,6 +6,7 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m
- RPC, DB helpers, workers, cron: `mem:backend/rpc-db-worker-subtleties`
- HTTP sessions, config, storage, media, file data persistence: `mem:backend/http-storage-filedata-subtleties`
- Embedded Ladybug graph experiment, projection, incremental sync, console, and risks: `mem:backend/graph-experiment`
- Auth flows, permission model, teams, projects, invitations, comments, webhooks, audit: `mem:backend/auth-permissions-product-domains`
- Services, task-queue/Pub-Sub topology constraints -> `mem:prod-infra/core`.
@@ -107,4 +108,3 @@ IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. J
* **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace.
* **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas.
* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.
@@ -0,0 +1,632 @@
# Graph Experiment
## Scope
- Purpose: project Penpot file data into an embedded Ladybug graph database.
- Purpose: keep the graph current with Penpot file changes.
- Purpose: expose a read-only graph console for backend debugging.
- This is an experiment, not a replacement for PostgreSQL file storage.
- The graph subsystem is off unless `:graph` is in the backend flags.
- The main Penpot frontend has no graph feature code for this subsystem.
- The graph console is a backend-served HTML template with JavaScript.
## Memory Links
- Read `mem:backend/core` for backend architecture, HTTP routes, DB rules, and test commands.
- Read `mem:backend/rpc-db-worker-subtleties` for RPC and message bus behavior.
- Read `mem:backend/http-storage-filedata-subtleties` for file data loading and realization.
- Read `mem:common/changes-architecture` for the change record vocabulary.
- Read `mem:frontend/routing-app-shell-subtleties` for the existing notification WebSocket.
- Read `mem:prod-infra/core` for Redis or Valkey message bus topology.
## Branch Surface
- The graph experiment adds about 6,336 lines and changes about 27 files.
- The graph implementation lives under `backend/src/app/graph/`.
- The graph console lives at `backend/resources/app/templates/graph-console.tmpl`.
- The existing debug page gains graph links in `backend/resources/app/templates/debug.tmpl`.
- The existing debug HTTP routes gain graph handlers in `backend/src/app/http/debug.clj`.
- The backend system passes the message bus to the debug route component in `backend/src/app/main.clj`.
- The backend adds Ladybug and Arrow dependencies in `backend/deps.edn`.
- The backend adds JVM options for Ladybug and Arrow native access.
- The common flag registry adds `:graph` in `common/src/app/common/flags.cljc`.
- The graph experiment adds `graph_sync_parity_test.clj` and `graph_binder_gate_test.clj`.
## System Model
### Storage layers
- PostgreSQL remains the source of truth for Penpot files.
- The graph database stores a projection of one file.
- A persistent graph uses a `.lbug` path under `PENPOT_GRAPH_DIR`.
- The default graph directory is `/tmp/penpot-graph`.
- A debug session uses a Ladybug `:memory:` database.
- A debug session database lives inside the backend JVM process.
- A debug session does not survive a backend restart.
- A debug session does not store file data back to PostgreSQL.
### Two graph update paths
- Cold projection reads the complete file and rebuilds the graph.
- Incremental sync reads file change records and updates the open graph.
- Both paths must produce the same graph for the same file state.
- The parity test treats cold projection as the reference path.
- A reload discards the session graph and uses cold projection again.
## Main Namespaces
### `app.graph.ladybug`
- Opens and closes Ladybug `Database` and `Connection` objects.
- Installs and loads the Ladybug JSON extension.
- Executes Cypher statements.
- Executes prepared statements.
- Binds scalar parameters.
- Formats UUID, string, integer, number, JSON, and timestamp values.
- Formats compound values such as arrays, maps, and structs.
- Converts Ladybug values back to Clojure values.
- Limits normal query results to 200 rows by default.
- Detects result truncation with `:truncated?`.
- Uses query timeout `0` by default.
- Query timeout `0` disables the timeout.
- Provides `validate-on-connection!` for parse, bind, and read-only checks.
- `exec-prepared-on-connection!` prepares every statement before the first execution.
- A prepare failure stops the batch before a mutation runs.
### `app.graph.schema`
- Provides the public schema facade.
- Exposes schema version `penpot-graph-slice-4`.
- Delegates node and relationship definitions to `app.graph.schema.nodes`.
### `app.graph.schema.nodes`
- Holds the single registry for graph node tables.
- Generates node DDL.
- Generates relationship DDL.
- Maps Penpot shape types to graph tables.
- Projects source attributes into graph attributes.
- Formats graph column values.
- Quotes reserved graph labels such as `Group` and `Boolean`.
- Defines container tables and shape tables.
- Defines `IsChildOf`, `IsInstanceOf`, `RefersTo`, and `FillsSwapSlot`.
### `app.graph.schema.contract`
- Records deliberate graph contract decisions.
- Renames graph columns such as `:revn` to `revision`.
- Drops attributes that do not belong in this graph slice.
- Records attributes that the graph does not project.
- Applies per-table dropped attributes.
- Defines type overrides for vectors, transforms, colors, maps, and JSON arrays.
- Maps selected map keys to the frontend JSON naming convention.
- `:background-blur` remains a declared unprojected attribute.
### `app.graph.schema.projection`
- Derives projected schemas from canonical Malli schemas.
- Builds the projected document schema.
- Builds projected shape schemas.
- Selects the schema for each shape type.
### `app.graph.schema.types`
- Maps Malli types to Ladybug types.
- Maps matrices to `DOUBLE[6]`.
- Maps points to `DOUBLE[2]`.
- Maps rectangles to `DOUBLE[4]`.
- Maps colors to `UINT32`.
- Maps collections to Ladybug arrays.
- Maps `:map-of` schemas to `MAP`.
- Maps closed scalar maps to `STRUCT`.
- Maps other complex values to `JSON`.
### `app.graph.schema.values`
- Coerces source values to graph column values.
- Writes fixed vectors with deterministic order.
- Packs colors into the graph color representation.
- Sorts set values when deterministic output is needed.
### `app.graph.arrow`
- Loads projection rows with Apache Arrow.
- Creates temporary staged node and relationship tables.
- Uses `COPY ... FROM (MATCH ...)` for bulk loading.
- Groups relationship loads by source and target table pair.
- Resolves relationship endpoints with joins.
- Does not use `createArrowRelTable` for UUID relationship endpoints.
- Keeps the Arrow `RootAllocator` alive until Ladybug releases staged buffers.
- Closes the allocator after the connection and database close sequence.
### `app.graph.ingest`
- Fetches a complete file with `bfc/get-file` and `:realize? true`.
- Rejects missing files.
- Rejects files without file data.
- Can run file data validation before projection.
- Creates the DDL.
- Loads nodes and edges through Arrow.
- Executes post-load transforms.
- Writes graph metadata last.
- Treats the final metadata write as the complete-build marker.
- Supports a persistent database path and an open connection.
### `app.graph.projection.document`
- Projects `Document`, `Page`, `Component`, and supported shape nodes.
- Skips the page root frame.
- Creates `IsChildOf` edges from shapes to parents.
- Creates page edges to the document.
- Creates component edges to the document.
- Stores page order in `Page.index` and edge `position`.
- Reverses the stored `:shapes` list for Penpot z-order.
- Adds `page-id` to every projected shape.
- Propagates an instance head `component-id` to descendants.
- Stops component inheritance at a non-Frame shape with its own component ID.
- Skips deleted components during cold projection.
- Logs unsupported shape types and missing shape records.
### `app.graph.projection.transforms`
- Runs after the base nodes and edges load.
- `link-component-instances` creates `IsInstanceOf` edges.
- A Frame needs `component-file` to qualify as an instance head.
- `link-shape-refs` creates `RefersTo` edges from `shape-ref`.
- Ladybug limits multi-label relationship `MERGE` statements.
- The transform emits one statement for each shape-table pair.
- `link-swap-slots` creates `FillsSwapSlot` edges.
- Swap slot IDs come from `swap-slot-<uuid>` entries in `touched`.
- The transform removes swap slot entries from `touched` after edge creation.
- The transform order matters because it reads and then changes `touched`.
### `app.graph.meta`
- Stores graph provenance in `GraphMeta`.
- Stores schema version, source revision, producer, and build time.
- The source revision identifies the file revision used for cold projection.
### `app.graph.stats` and `app.graph.report`
- `app.graph.stats` counts graph nodes and relationships from the live catalog.
- `app.graph.report` prints ingest information for REPL use.
## Cold Projection Flow
1. Get the file row and realized file data from PostgreSQL.
2. Read the file revision from the file row.
3. Build the node and edge projection.
4. Create all graph tables from the graph schema.
5. Load node rows with Arrow.
6. Load relationship rows with Arrow.
7. Run `CHECKPOINT;`.
8. Run the registered derived transforms.
9. Write `GraphMeta` as the final build step.
10. Return file ID, file revision, database path, projection stats, and transform stats.
### Projection node groups
- `Document` contains file-level attributes without the file data blob.
- `Document.options` receives file-level options from the data blob.
- `Page` contains page attributes without the page object map.
- `Component` contains component attributes without component object maps.
- Shape tables contain the supported shape attributes.
- The graph stores selected derived attributes such as `page-id`.
### Projection relationship groups
- Structural edges use `IsChildOf`.
- Page and component edges point to `Document`.
- Derived edges come from the post-load transform registry.
## Incremental Sync
### Change source
- `app.rpc.commands.files-update` persists the file update first.
- The same command publishes a `:file-change` message to the file topic.
- The topic key is the file UUID.
- The message contains the file ID, profile ID, session ID, revision, version, and changes.
- Library changes also publish a team-topic message.
- The graph session only consumes the file-topic `:file-change` messages.
### Session subscription
- `app.graph.debug/start-sync-loop!` creates a channel with a dropping buffer of 64.
- The session subscribes the channel to the file UUID topic.
- The loop reads one message at a time.
- The loop ignores message types other than `:file-change`.
- The loop stops when the channel closes.
- `destroy-session!` closes the channel and purges its message bus subscription.
### Session state
- Sessions are stored in a global `defonce` atom.
- The map key is the string form of `profile-id`.
- One profile has one graph session.
- Loading another file first destroys the old session.
- A session stores the Ladybug database and connection.
- A session stores a shared lock for graph access.
- A session stores file metadata.
- A session stores the incremental sync index.
- A session stores the message bus channel.
- A session stores load time and profile ID.
- The session keeps projection statistics but drops full projection rows after index creation.
### Sync index
- `build-index` starts from the complete cold projection.
- The index stores the graph file ID and document ID.
- The index stores the current graph revision.
- The index stores page IDs, names, and positions.
- The index stores component IDs, names, and deleted state.
- The index stores shape table, parent, position, frame, page, and component context.
- The index stores child IDs by parent ID.
- The index supports later change application without another PostgreSQL file read.
### Change application
- `apply-changes!` processes the change list in source order.
- Each supported change returns a new index and a list of Cypher statements.
- Unsupported changes enter the `:skipped` result.
- Supported changes enter the `:applied` result.
- The function collects all statements before it executes them.
- The function appends a document revision statement when at least one change applies.
- The index revision advances only when at least one change applies.
- A larger incoming revision than the index revision creates a warning.
- A revision gap does not trigger catch-up.
### Shape change rules
- `:add-obj` reuses `projection.document/denormalized-shape`.
- `:add-obj` creates the shape node and its parent edge.
- `:mod-obj` applies supported `:set` operations to graph columns.
- `:mod-obj` keeps false and zero values as values.
- `:del-obj` deletes shapes in deep post-order.
- `:mov-objects` detaches shapes from the old parent.
- `:mov-objects` closes the old sibling position gap.
- `:mov-objects` inserts shapes at the new position.
- `:mov-objects` updates `parent_id` and `frame_id`.
- `:mov-objects` rewrites container `shapes` values.
- The parent columns and child lists must match a cold projection.
### Page and component change rules
- Page add creates a projected page node and a document edge.
- Page delete removes the page subtree.
- Page modification updates supported page attributes.
- Component add creates a component node and document edge.
- Component modification updates supported component attributes.
- Component delete uses a soft-delete state.
- Component restore removes the soft-delete state.
- Component purge removes the component node and document edge.
- Component sync paths need more parity coverage than the current tests provide.
## Session Locking
- The sync loop and HTTP handlers share one lock per session.
- The lock protects one Ladybug connection from concurrent access.
- Queries acquire the lock before binder validation and execution.
- Graph data export acquires the lock before catalog reads.
- Session export acquires the lock before `EXPORT DATABASE`.
- A long query blocks sync for the same session.
- A sync batch blocks queries for the same session.
- Ladybug connection thread safety is not assumed.
## Graph Query Rules
- The console accepts Cypher text.
- Blank query text raises a validation error.
- The query first passes Ladybug prepare and bind checks.
- The query must pass the engine read-only analysis.
- A mutating query is rejected.
- The graph console does not provide a write path.
- A session graph is rebuilt from the file by Reload.
- Normal query results have a 200-row limit.
- Query results use string values for the HTML console representation.
- JSON requests receive a Transit JSON response with the query and result.
- HTML requests receive the rendered console with the result.
## Graph Data Export
### G6 data
- `/dbg/actions/graph-data` reads the live Ladybug database.
- It does not read the sync index for nodes and edges.
- It therefore shows database drift if a batch fails after index update.
- Node export covers all registered node tables.
- Relationship export reads the Ladybug relationship catalog.
- Relationship export includes source, target, relationship name, and position.
- Node and relationship export uses a 100,000-row limit.
- The response reports `truncated` when a limit cuts the result.
- The response reports buffer-manager memory usage.
### `.lbug` export
- `source=file` rebuilds the persistent graph from PostgreSQL file data.
- `source=file` runs a synchronous full ingest for each request.
- `source=session` exports the caller profile's live in-memory graph.
- Session export uses Ladybug `EXPORT DATABASE` to Parquet files.
- Session export creates a new `.lbug` database with `IMPORT DATABASE`.
- The temporary Parquet staging directory is deleted after import.
- The final session `.lbug` file remains in the system temporary directory.
- The HTTP response streams the database file to the caller.
## HTTP Routes and Access
- The graph routes live in `backend/src/app/http/debug.clj`.
- The graph route list is added only when `:graph` is enabled.
- `/dbg/graph` serves the graph console page.
- `/dbg/actions/graph-files` returns the profile file tree.
- `/dbg/actions/graph-load` loads a file into the profile session.
- `/dbg/actions/graph-unload` closes the profile session.
- `/dbg/actions/graph-reload` rebuilds the loaded file graph.
- `/dbg/actions/graph-query` runs a read-only Cypher query.
- `/dbg/actions/graph-sync-status` returns the sync state.
- `/dbg/actions/graph-data` returns nodes and edges for G6.
- `/dbg/actions/graph-export` streams a `.lbug` database.
- The `/dbg` session middleware remains active.
- The `/dbg` admin middleware remains active.
- A devenv host with a profile ID passes the debug authorization rule.
- Other hosts need a profile email in the configured admin set.
- `/dbg/actions/graph-files` lists reachable teams, projects, and files.
- The file tree query has a 500-file limit.
- The graph handlers resolve graph namespaces at call time.
- The backend requires `app.graph.debug` and `app.graph.ingest` when the flag is on.
- Ladybug native loading then fails during route initialization instead of first use.
## Console Frontend
### Page type
- `graph-console.tmpl` is a backend resource template.
- It is not a Rumext component.
- It is not part of the main frontend route table.
- The page uses browser `fetch` calls and a browser WebSocket.
- The page loads G6 version `5.1.1` from jsDelivr.
### File tree
- The page fetches `/dbg/actions/graph-files`.
- The response contains team, project, and file groups.
- The page creates the tree with DOM APIs.
- A file click submits the graph load form.
- The page shows a message when no file exists.
### Graph rendering
- The page fetches `/dbg/actions/graph-data`.
- The page converts graph nodes and edges to G6 data.
- The page skips repaint when the node and edge signature does not change.
- The page marks added, removed, and changed graph entities.
- The page supports tree, dagre, circular, force, and combo layouts.
- The page supports collapsed container combos.
- The page has render guards at 4,000 nodes and 8,000 edges.
- The `?safe` query option bypasses the render guard.
- The page shows graph size by node count and relationship count.
- The page shows buffer-manager memory in MiB.
- The page reports a CDN failure when G6 is undefined.
### Query result filtering
- A query can return `filter_*` columns with node IDs.
- The HTML result table hides columns with the `filter_` prefix.
- The JSON result keeps the full result.
- The graph view uses the hidden IDs to select matching nodes.
- The graph view re-runs the query after graph refresh.
- This keeps the query filter aligned with the current graph.
- A user column named `filter_*` follows the same hiding rule.
### Node inspector
- A node click creates a query for that node.
- The inspector calls `/dbg/actions/graph-query` with JSON negotiation.
- The inspector displays the full projected row.
- The inspector uses table and ID values from the graph data.
## WebSocket Data Flow
1. The page opens `/ws/notifications` with a random `session-id` query value.
2. The page sends `:subscribe-file` with a Transit UUID value.
3. The server makes sure that the file exists and that the profile has read permission.
4. The server subscribes the connection to the file topic.
5. `files_update` publishes `:file-change` to the same topic.
6. The graph session consumes the message from its message bus subscription.
7. The WebSocket server sends the message to the browser connection.
8. The browser adds the change to the changelog.
9. The browser fetches sync status after 150 milliseconds.
10. The browser fetches graph data after a 400-millisecond debounce.
11. The browser repaints the G6 graph when the graph data changes.
### WebSocket reconnect behavior
- The page reconnects after three seconds when the socket closes.
- The page resubscribes to the file after the socket opens.
- The page refreshes sync status after reconnect.
- The page refreshes graph data after reconnect.
- Reconnect does not recover dropped message-bus changes.
- The page shows the sync error or skipped-change state when the status reports it.
## Feature Flag and Runtime Dependencies
- `:graph` is defined in `common/src/app/common/flags.cljc`.
- The flag is off by default.
- `com.ladybugdb/lbug` version `0.19.1` is a backend dependency.
- `org.apache.arrow/arrow-memory-netty` version `18.2.0` supports Arrow `RootAllocator`.
- The JVM uses `--enable-native-access=ALL-UNNAMED`.
- The JVM uses `--add-opens=java.base/java.nio=ALL-UNNAMED`.
- The JVM uses `--sun-misc-unsafe-memory-access=allow`.
- The JVM options appear in the development alias and backend launch scripts.
- A Ladybug version change needs new binder and parity tests.
- A JDK version change needs a startup test with the graph flag enabled.
## Tests
### `backend-tests.graph-sync-parity-test`
- Uses two Ladybug `:memory:` databases.
- Does not use PostgreSQL or a live graph session.
- Projects initial file data into database A.
- Applies changes to database A through incremental sync.
- Applies the same changes to file data.
- Projects the changed file data into database B.
- Compares every node row and relationship row.
- Reports differences by table, row key, and column.
- Covers shape add, shape modification, shape deletion, movement, and page changes.
- Contains a test that injects a sync defect and expects a graph difference.
- Does not cover all component change variants.
- Does not cover every movement insertion mode.
### `backend-tests.graph-binder-gate-test`
- Creates the live graph DDL in a Ladybug `:memory:` database.
- Prepares each sync statement template without executing it.
- Detects parse errors and missing tables.
- Detects missing columns and bad label quoting.
- Reports the expected read-only classification.
- Covers reserved node labels across the node registry.
- Reports an error result for an invalid statement.
### Test gaps
- No automated HTTP handler tests cover graph routes.
- No automated session lifecycle tests cover load and unload.
- No automated WebSocket tests cover graph subscription.
- No automated export tests cover persistent and session sources.
- Component add, modify, delete, restore, and purge need parity tests.
- Page delete needs parity coverage.
- Movement with `:after-shape` needs parity coverage.
- Buffer overflow and revision gap behavior need tests.
- Partial batch failure and recovery need tests.
- Query timeout and long-query behavior need tests.
## Known Risks and Limits
### Dropped changes
- The sync channel uses a dropping buffer of 64.
- A burst can discard file-change messages.
- The sync loop logs a revision gap when it sees a larger revision.
- The sync loop does not fetch missing rows from `file_change`.
- Reload is the only built-in recovery path.
### Partial batch state
- `apply-changes!` does not provide Ladybug transaction atomicity.
- A statement failure can leave a partly changed graph.
- The in-memory index can advance before the database state is complete.
- `/dbg/actions/graph-data` reads the database and exposes this drift.
- Reload rebuilds the graph from PostgreSQL file data.
### Query resource use
- The default session query timeout is zero.
- A costly query can hold the session lock for a long time.
- The same lock blocks incremental sync.
- The graph export also holds the same lock during catalog reads.
- The graph schema has a high memory floor.
- The console reports about 115 MiB for the wide slice before file data.
### Session lifecycle
- Sessions have no TTL.
- Sessions remain until unload, replacement, or process shutdown.
- Each session owns native Ladybug memory.
- Many profiles can create many native databases.
- A profile load replaces its previous session.
- Two browser tabs for one profile share one graph session.
### Temporary files
- Session export leaves the final `.lbug` file in the system temporary directory.
- Long-lived servers can accumulate exported session databases.
- The staging directory is deleted after import.
### Browser dependency
- The graph view depends on a runtime CDN request.
- A network restriction can remove the G6 view.
- Queries and session status still use backend endpoints without G6.
### Data exposure
- The graph console can list many files available to the profile.
- The console can load complete projected file data.
- The console can export a graph database.
- The console can inspect all projected node attributes.
- The console is safe only when the `/dbg` access boundary is correct.
- The graph flag must remain off for deployments that do not need this tool.
### Contract drift
- The graph schema is a deliberate slice of the Penpot file model.
- New source attributes do not enter the graph automatically in all cases.
- Dropped and unprojected attributes need an explicit contract decision.
- `applied_tokens` key mapping depends on the JSON naming function.
- `filter_*` is a frontend convention, not a graph schema guarantee.
### Ladybug dialect coupling
- Cypher strings contain Ladybug-specific syntax.
- Label quoting handles reserved labels explicitly.
- Relationship transforms depend on Ladybug relationship limits.
- Arrow loading depends on Ladybug `COPY FROM (MATCH ...)` behavior.
- A dependency upgrade needs schema, binder, Arrow, and parity checks.
## REPL Helpers
- `app.srepl.main` resolves graph functions only when a helper runs.
- `graph-smoke-test!` runs a basic Ladybug operation.
- `graph-query-test!` runs a graph query test.
- `ingest-file-to-graph!` projects a file into a graph database.
- These helpers use `requiring-resolve` to keep the graph dependency lazy.
## Operational Invariants
- PostgreSQL file data remains authoritative.
- Cold projection and incremental sync must produce equal graph state.
- The graph revision must identify the last applied file revision.
- The document revision must update when a sync batch applies.
- A missing or skipped change must remain visible in sync status.
- A graph query from the console must be read-only.
- A graph session must serialize connection access.
- Graph routes must remain behind the `:graph` flag and `/dbg` access control.
- The Arrow allocator must outlive all Ladybug operations that use its buffers.
- `GraphMeta` must be written after the full ingest and transforms finish.
## Key Files
- `backend/src/app/graph/ladybug.clj`: Ladybug API and query gates.
- `backend/src/app/graph/arrow.clj`: Arrow bulk load.
- `backend/src/app/graph/ingest.clj`: Complete file ingest.
- `backend/src/app/graph/debug.clj`: Session lifecycle, sync loop, query, and export.
- `backend/src/app/graph/sync.clj`: Incremental change application.
- `backend/src/app/graph/meta.clj`: Graph provenance.
- `backend/src/app/graph/stats.clj`: Graph counts.
- `backend/src/app/graph/report.clj`: REPL ingest report.
- `backend/src/app/graph/projection/document.clj`: Base document projection.
- `backend/src/app/graph/projection/transforms.clj`: Derived relationship transforms.
- `backend/src/app/graph/schema/nodes.clj`: Node and relationship registry.
- `backend/src/app/graph/schema/contract.clj`: Projection contract decisions.
- `backend/src/app/graph/schema/projection.clj`: Malli projection schemas.
- `backend/src/app/graph/schema/types.clj`: Malli-to-Ladybug type mapping.
- `backend/src/app/graph/schema/values.clj`: Value coercion.
- `backend/src/app/http/debug.clj`: Graph route registration and handlers.
- `backend/src/app/http/websocket.clj`: File WebSocket subscription handlers.
- `backend/src/app/rpc/commands/files_update.clj`: File-change publication.
- `backend/src/app/main.clj`: Integrant message bus wiring.
- `backend/resources/app/templates/graph-console.tmpl`: Graph console browser code.
- `backend/resources/app/templates/debug.tmpl`: Debug page graph links.
- `common/src/app/common/flags.cljc`: `:graph` feature flag.
- `backend/test/backend_tests/graph_sync_parity_test.clj`: Cold versus sync parity.
- `backend/test/backend_tests/graph_binder_gate_test.clj`: Cypher binder gate.
## Development Commands
- Run backend commands from the `backend/` directory.
- Run focused parity tests with `clojure -M:dev:test --focus backend-tests.graph-sync-parity-test`.
- Run focused binder tests with `clojure -M:dev:test --focus backend-tests.graph-binder-gate-test`.
- Run the backend test suite with `clojure -M:dev:test`.
- Examine Clojure formatting with `pnpm run check-fmt:clj`.
- Run backend Clojure lint with `pnpm run lint:clj`.
- Write test output to a file before reading or filtering it.
@@ -17,6 +17,8 @@
## Tile/render behavior
- Raster `Fill::Image`: skip `save_layer` unless the shape has an image filter; plain
Rect/Frame (no corners) also skip the container clip (`draw_image_fill` in fills.rs).
- Interactive transforms are distinct from viewport fast mode. `set_modifiers_start` enables fast mode and interactive transform; interactive transform still flushes each animation frame.
- During interactive transform, modifier tile invalidation is deferred to `render()` once per rAF. Outside interactive transform, `set_modifiers` rebuilds modifier tiles immediately.
- `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render.
+8 -2
View File
@@ -65,13 +65,19 @@
;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
software.amazon.awssdk/s3 {:mvn/version "2.50.1"}
software.amazon.awssdk/sts {:mvn/version "2.50.1"}}
software.amazon.awssdk/sts {:mvn/version "2.50.1"}
com.ladybugdb/lbug {:mvn/version "0.19.1"}
;; Required by Arrow RootAllocator (lbug only pulls arrow-memory-core).
org.apache.arrow/arrow-memory-netty {:mvn/version "18.2.0"}}
:paths ["src" "resources" "target/classes"]
:aliases
{:dev
{:jvm-opts ["--sun-misc-unsafe-memory-access=allow"
"--enable-native-access=ALL-UNNAMED"]
"--enable-native-access=ALL-UNNAMED"
;; Arrow jars are on the classpath (unnamed module), not module-path.
"--add-opens=java.base/java.nio=ALL-UNNAMED"]
:extra-deps
{com.bhauman/rebel-readline {:mvn/version "0.1.11"}
clojure-humanize/clojure-humanize {:mvn/version "0.2.2"}
File diff suppressed because it is too large. Load diff
@@ -222,6 +222,23 @@ Debug Main Page
</div>
</form>
</fieldset>
{% if graph-enabled %}
<fieldset>
<legend>Export graph (Ladybug):</legend>
<desc>Given a FILE-ID, builds the graph projection and downloads
the `.lbug` database file.</desc>
<form method="get" action="/dbg/actions/graph-export">
<div class="row">
<input type="text" style="width:300px" name="file-id" placeholder="file-id" />
</div>
<div class="row">
<input type="submit" value="Download .lbug" />
<a href="/dbg/graph">Open graph console</a>
</div>
</form>
</fieldset>
{% endif %}
<fieldset>
<legend>Import binfile:</legend>
<desc>Import penpot file in binary format.</desc>
File diff suppressed because it is too large. Load diff
+6 -3
View File
@@ -13,6 +13,10 @@ export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
# PENPOT_DATABASE_*, PENPOT_REDIS_URI, PENPOT_OBJECTS_STORAGE_*, AWS_*) is owned by
# docker/devenv/defaults.env and injected via the main service's env block.
if [ -f /home/selfsigned.crt ]; then
export NODE_EXTRA_CA_CERTS=/home/selfsigned.crt;
fi
# Background worker flag is per-instance. Defaults to enabled (ws0); ws1+
# overlays set PENPOT_BACKEND_WORKER=false so scheduled and async tasks only
# run on ws0, keeping notification Pub/Sub bound to a single Valkey. See
@@ -89,7 +93,8 @@ export JAVA_OPTS="\
-XX:-OmitStackTraceInFastThrow \
--sun-misc-unsafe-memory-access=allow \
--enable-preview \
--enable-native-access=ALL-UNNAMED";
--enable-native-access=ALL-UNNAMED \
--add-opens=java.base/java.nio=ALL-UNNAMED";
function setup_minio() {
if [ "${PENPOT_OBJECTS_STORAGE_BACKEND}" != "s3" ]; then
@@ -101,5 +106,3 @@ function setup_minio() {
mc alias set penpot-s3/ "${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}" minioadmin minioadmin -q
mc mb "penpot-s3/${PENPOT_OBJECTS_STORAGE_S3_BUCKET}" -p -q
}
+1 -1
View File
@@ -18,7 +18,7 @@ if [ -f ./environ ]; then
source ./environ
fi
export JAVA_OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow --sun-misc-unsafe-memory-access=allow --enable-native-access=ALL-UNNAMED --enable-preview $JVM_OPTS $JAVA_OPTS"
export JAVA_OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow --sun-misc-unsafe-memory-access=allow --enable-native-access=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --enable-preview $JVM_OPTS $JAVA_OPTS"
ENTRYPOINT=${1:-app.main};
+370
View File
@@ -0,0 +1,370 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.arrow
"Bulk Ladybug ingest through in-memory Arrow.
Rows are built as Arrow `VectorSchemaRoot`s in the JVM's off-heap memory,
handed to Ladybug as a virtual table, and `COPY`d into the real one. No file
is written and no value is rendered as text for the engine to re-parse, so
nothing in this path needs escaping. Arrow carries MAP, STRUCT, fixed-size
arrays and multi-line strings natively.
The type language is Ladybug's, read recursively by `app.graph.schema.values`;
this namespace adds the matching Arrow `Field` and a writer for each shape.
`values/coerce` shapes a value first — a matrix into six doubles, a colour
into a packed integer — exactly as it does for the Cypher path, so the two
writers cannot disagree.
Engine facts this file depends on, each verified against lbug 0.19.1:
- An Arrow table is **not** a `COPY` source identifier, but it *is* a
MATCH-able node label: `COPY T FROM (MATCH (n:stg) RETURN n.a AS a, …)`.
- A MAP vector's `entries` child struct must be non-nullable, and
`MapVector/getWriter` silently promotes it to a sparse union — so map
vectors are built from an explicit `Field` and filled child-first.
- Ladybug quotes the column and table names it interpolates into the staged
table's DDL, and does not quote a STRUCT member name. So a top-level field
arrives plain and a struct member whose name is a reserved word (`column`)
arrives backticked.
- `createArrowRelTable` resolves a UUID-keyed endpoint only from a
`FixedSizeBinary(16)` column carrying the `arrow.uuid` extension, so edges
are staged as a node table and joined by the `COPY` subquery instead."
(:require
[app.common.json :as json]
[app.graph.ladybug :as ladybug]
[app.graph.schema.nodes :as nodes]
[app.graph.schema.values :as values]
[clojure.string :as str])
(:import
com.ladybugdb.Connection
com.ladybugdb.QueryResult
java.nio.charset.StandardCharsets
java.util.ArrayList
java.util.List
org.apache.arrow.memory.BufferAllocator
org.apache.arrow.memory.RootAllocator
org.apache.arrow.vector.BigIntVector
org.apache.arrow.vector.BitVector
org.apache.arrow.vector.complex.ListVector
org.apache.arrow.vector.complex.MapVector
org.apache.arrow.vector.complex.StructVector
org.apache.arrow.vector.FieldVector
org.apache.arrow.vector.Float8Vector
org.apache.arrow.vector.TimeStampMicroVector
org.apache.arrow.vector.types.FloatingPointPrecision
org.apache.arrow.vector.types.pojo.ArrowType$Bool
org.apache.arrow.vector.types.pojo.ArrowType$FloatingPoint
org.apache.arrow.vector.types.pojo.ArrowType$Int
org.apache.arrow.vector.types.pojo.ArrowType$List
org.apache.arrow.vector.types.pojo.ArrowType$Map
org.apache.arrow.vector.types.pojo.ArrowType$Struct
org.apache.arrow.vector.types.pojo.ArrowType$Timestamp
org.apache.arrow.vector.types.pojo.ArrowType$Utf8
org.apache.arrow.vector.types.pojo.Field
org.apache.arrow.vector.types.pojo.FieldType
org.apache.arrow.vector.types.pojo.Schema
org.apache.arrow.vector.types.TimeUnit
org.apache.arrow.vector.UInt4Vector
org.apache.arrow.vector.VarCharVector
org.apache.arrow.vector.VectorSchemaRoot))
(set! *warn-on-reflection* true)
;; --------------------------------------------------------------- allocator
(defn with-allocator!
"Invoke `(f allocator)` with a fresh Arrow `RootAllocator`.
The allocator must outlive the Ladybug connection, because Ladybug releases
its references to the staged buffers only when the Arrow tables are dropped —
which happens on connection close at the latest. Closing it first surfaces as
`IllegalStateException: Memory was leaked`, *thrown while unwinding*, which
hides whatever actually failed. Any diagnostic here must catch inside this
scope."
[f]
(with-open [allocator (RootAllocator.)]
(f allocator)))
;; ------------------------------------------------------ Ladybug type → Field
(def ^:private scalar-arrow-type
"Ladybug scalar → Arrow type. `UUID` and `JSON` ride as UTF-8: Ladybug
accepts a string into either column and does the conversion itself, which is
cheaper than teaching this side two more binary layouts."
{"STRING" #(ArrowType$Utf8.)
"UUID" #(ArrowType$Utf8.)
"JSON" #(ArrowType$Utf8.)
"INT64" #(ArrowType$Int. 64 true)
"UINT32" #(ArrowType$Int. 32 false)
"DOUBLE" #(ArrowType$FloatingPoint. FloatingPointPrecision/DOUBLE)
"BOOLEAN" #(ArrowType$Bool.)
"TIMESTAMP" #(ArrowType$Timestamp. TimeUnit/MICROSECOND nil)})
(defn column-field
"Arrow `Field` for a column of `ladybug-type`, recursively.
`nullable?` is false only where Arrow's own invariants demand it — a MAP's
`entries` struct and its key."
(^Field [^String field-name ladybug-type]
(column-field field-name ladybug-type true))
(^Field [^String field-name ladybug-type nullable?]
(cond
;; A list first: `STRUCT(…)[]` starts with `STRUCT(` but is a list of them.
(ladybug/list-type? ladybug-type)
(Field. field-name (FieldType. nullable? (ArrowType$List.) nil)
[(column-field "item" (values/list-element ladybug-type))])
(ladybug/map-type? ladybug-type)
(let [[key-type value-type] (values/map-types ladybug-type)]
(Field. field-name (FieldType. nullable? (ArrowType$Map. false) nil)
[(Field. "entries" (FieldType. false (ArrowType$Struct.) nil)
[(column-field "key" key-type false)
(column-field "value" value-type)])]))
(ladybug/struct-type? ladybug-type)
(Field. field-name (FieldType. nullable? (ArrowType$Struct.) nil)
;; Backticks kept: Ladybug quotes none of these when it names the
;; staged struct's fields, so `column` has to arrive quoted.
(mapv (fn [[field field-type]] (column-field field field-type))
(values/struct-fields-quoted ladybug-type)))
:else
(if-let [mk (get scalar-arrow-type ladybug-type)]
(Field. field-name (FieldType. nullable? (mk) nil) nil)
(throw (ex-info (str "no Arrow mapping for Ladybug type: " ladybug-type)
{:ladybug-type ladybug-type}))))))
;; ------------------------------------------------------------------- writer
(defn- utf8
^bytes [v]
(.getBytes (if (keyword? v) (name v) (str v)) StandardCharsets/UTF_8))
(defn- epoch-micros
^long [v]
(let [^java.time.Instant inst
(cond
(instance? java.time.Instant v) v
(instance? java.util.Date v) (.toInstant ^java.util.Date v)
:else (java.time.Instant/parse (str v)))]
(+ (* (.getEpochSecond inst) 1000000) (long (quot (.getNano inst) 1000)))))
(defn- write-scalar!
[^FieldVector fv ladybug-type ^long idx v]
(case ladybug-type
("STRING" "UUID") (.setSafe ^VarCharVector fv idx (utf8 v))
;; A JSON column holds JSON, not a Clojure value's print form: `str` on a
;; map yields `{:fill-color "#000000"}`, which is EDN and which every
;; consumer of `fills`, `content` or `position_data` would fail to parse.
;; Same encoder the Cypher path uses (`app.graph.ladybug/format-json`).
"JSON" (.setSafe ^VarCharVector fv idx
(.getBytes ^String (json/encode v)
StandardCharsets/UTF_8))
"INT64" (.setSafe ^BigIntVector fv idx (long v))
"UINT32" (.setSafe ^UInt4Vector fv idx (unchecked-int (long v)))
"DOUBLE" (.setSafe ^Float8Vector fv idx (double v))
"BOOLEAN" (.setSafe ^BitVector fv idx (if v 1 0))
"TIMESTAMP" (.setSafe ^TimeStampMicroVector fv idx (epoch-micros v))
(throw (ex-info (str "no Arrow writer for Ladybug type: " ladybug-type)
{:ladybug-type ladybug-type}))))
(defn write-value!
"Write already-coerced `v` into `fv` at `idx`, per `ladybug-type`.
`map-key-fn` renders the keys of a `MAP(STRING, …)`, for the same reason
`app.graph.ladybug/format-typed-value` takes one: the right spelling is a
property of the column, not of the writer."
;; `idx` is deliberately unhinted: Clojure only accepts primitive args on fns
;; of four or fewer, and the map-key renderer has to travel with the value.
[^FieldVector fv ladybug-type idx v map-key-fn]
(if (nil? v)
(.setNull fv (int idx))
(cond
(ladybug/list-type? ladybug-type)
(let [^ListVector lv fv
child (.getDataVector lv)
element-type (values/list-element ladybug-type)
elements (vec (if (or (sequential? v) (set? v)) v [v]))
start (.startNewValue lv (int idx))]
(dotimes [i (count elements)]
(write-value! child element-type (+ start i) (nth elements i) map-key-fn))
(.endValue lv (int idx) (count elements)))
(ladybug/map-type? ladybug-type)
(let [^MapVector mv fv
^StructVector entries (.getDataVector mv)
[key-type value-type] (values/map-types ladybug-type)
key-vec (.getChild entries "key")
value-vec (.getChild entries "value")
render-key (if (and map-key-fn (= "STRING" key-type)) map-key-fn identity)
pairs (vec (seq v))
start (.startNewValue mv (int idx))]
(dotimes [i (count pairs)]
(let [[k mv'] (nth pairs i)
at (+ start i)]
;; The entries struct is non-nullable: every slot must be defined.
(.setIndexDefined entries (int at))
(write-value! key-vec key-type at (render-key k) nil)
(write-value! value-vec value-type at mv' map-key-fn)))
(.endValue mv (int idx) (count pairs)))
(ladybug/struct-type? ladybug-type)
(let [^StructVector sv fv]
(.setIndexDefined sv (int idx))
(doseq [[quoted-field field-type] (values/struct-fields-quoted ladybug-type)]
;; The child is named with its backticks; the coerced value is keyed
;; without them.
(write-value! (.getChild sv quoted-field) field-type idx
(get v (str/replace quoted-field "`" "")) map-key-fn)))
:else
(write-scalar! fv ladybug-type (long idx) v))))
;; ------------------------------------------------------------------ batches
(defn- fill-vector!
[^VectorSchemaRoot root ^String field-name ladybug-type rows value-fn map-key-fn]
(let [^FieldVector fv (.getVector root field-name)]
(.allocateNew fv)
(dotimes [i (count rows)]
(write-value! fv ladybug-type i
(values/coerce ladybug-type (value-fn (nth rows i)))
map-key-fn))
(.setValueCount fv (count rows))))
(defn- node-batch
"One `VectorSchemaRoot` holding every projected row of `table`.
Fields carry the plain column name. Ladybug quotes every identifier it
interpolates into the staged table's DDL, so a name that is a reserved word
(`Page.index`, `Document.options`) arrives unquoted and a name arriving
pre-quoted comes out doubly backticked and fails to parse. The `COPY`
projection below is Cypher, not DDL, so it quotes the same names itself."
^VectorSchemaRoot [^BufferAllocator allocator table rows]
(let [columns (nodes/column-keys table)
fields (mapv (fn [k] (column-field (nodes/column-name table k)
(nodes/column-ladybug-type table k)))
columns)
root (VectorSchemaRoot/create (Schema. ^List fields) allocator)]
(doseq [k columns]
(fill-vector! root (nodes/column-name table k)
(nodes/column-ladybug-type table k)
rows #(get % k) (nodes/column-map-key-fn table k)))
(.setRowCount root (count rows))
root))
(def ^:private edge-fields
"Edge staging columns. `id` is the staging table's own key — Ladybug wants a
first column to key the virtual table on — and `from`/`to` land as STRING,
hence the cast in the join."
[(Field. "id" (FieldType. true (ArrowType$Utf8.) nil) nil)
(Field. "from" (FieldType. true (ArrowType$Utf8.) nil) nil)
(Field. "to" (FieldType. true (ArrowType$Utf8.) nil) nil)
(Field. "position" (FieldType. true (ArrowType$Int. 64 true) nil) nil)])
(defn- edge-batch
^VectorSchemaRoot [^BufferAllocator allocator edges]
(let [root (VectorSchemaRoot/create (Schema. ^List edge-fields) allocator)
^VarCharVector iv (.getVector root "id")
^VarCharVector fv (.getVector root "from")
^VarCharVector tv (.getVector root "to")
^BigIntVector pv (.getVector root "position")
n (count edges)]
(doseq [^FieldVector v [iv fv tv pv]] (.allocateNew v))
(dotimes [i n]
(let [{:keys [from-id to-id position]} (nth edges i)]
(.setSafe iv i (utf8 i))
(.setSafe fv i (utf8 from-id))
(.setSafe tv i (utf8 to-id))
(if (nil? position) (.setNull pv i) (.setSafe pv i (long position)))))
(doseq [^FieldVector v [iv fv tv pv]] (.setValueCount v n))
(.setRowCount root n)
root))
;; ------------------------------------------------------------------ staging
(defn- batches
^List [^VectorSchemaRoot root]
(doto (ArrayList.) (.add root)))
(defn- check!
[^QueryResult result hint data]
(when-not (.isSuccess result)
(throw (ex-info (str hint ": " (.getErrorMessage result))
(assoc data :err (.getErrorMessage result))))))
(defn- with-staged-table!
"Create Arrow table `staging-name` from `root`, run `(f)`, always drop it."
[^Connection conn ^BufferAllocator allocator ^String staging-name
^VectorSchemaRoot root data f]
(try
(with-open [^QueryResult r (.createArrowTable conn staging-name (batches root) allocator)]
(check! r "createArrowTable failed" data))
(f)
(finally
;; Dropped even on failure: the staged buffers stay referenced by Ladybug
;; until it is, and the allocator's leak check fires on close otherwise.
(try (.close ^QueryResult (.dropArrowTable conn staging-name))
(catch Throwable _ nil)))))
(defn- copy-node-table!
[^Connection conn table ^String staging-name]
(let [projection (str/join ", " (for [k (nodes/column-keys table)
:let [c (nodes/cypher-property-key table k)]]
(str "n." c " AS " c)))
statement (str "COPY `" table "` FROM (MATCH (n:" staging-name ") "
"RETURN " projection ");")]
(with-open [^QueryResult r (.query conn statement)]
(check! r (str "COPY node table failed: " table)
{:table table :statement statement}))))
(defn- copy-edge-group!
"Load one FROM/TO pair of `IsChildOf`.
`createArrowRelTable` is unusable here — it cannot resolve endpoints against a
UUID-keyed node table — so the edge list is staged as a node table and the
endpoints are resolved by the subquery. The `WHERE` is clause-level because
this dialect prohibits an inline pattern `WHERE`, and both sides are pinned by
label so the join cannot reach outside the pair."
[^Connection conn from-table to-table ^String staging-name]
(let [statement (str "COPY `IsChildOf` FROM ("
"MATCH (e:" staging-name "), "
"(a:" (nodes/match-label from-table) "), "
"(b:" (nodes/match-label to-table) ") "
"WHERE a.id = cast(e.from AS UUID) "
"AND b.id = cast(e.to AS UUID) "
"RETURN a.id, b.id, e.position) "
"(from='" from-table "', to='" to-table "');")]
(with-open [^QueryResult r (.query conn statement)]
(check! r (str "COPY edge group failed: " from-table " -> " to-table)
{:from-table from-table :to-table to-table :statement statement}))))
(defn- staging-name
[prefix & parts]
(str/replace (str/join "_" (cons (str "stg_" prefix) parts)) #"[^A-Za-z0-9_]" "_"))
;; --------------------------------------------------------------------- load
(defn load-projection!
"Load projected nodes and edges into an open Ladybug connection.
`allocator` must outlive `conn` — see `with-allocator!`."
[^Connection conn {:keys [nodes edges]} ^BufferAllocator allocator]
(doseq [[table rows] (sort-by key nodes)
:when (seq rows)]
(let [name (staging-name "node" table)]
(with-open [root (node-batch allocator table rows)]
(with-staged-table! conn allocator name root {:table table}
#(copy-node-table! conn table name)))))
(doseq [[[from-table to-table] group]
(sort-by key (group-by (juxt :from-table :to-table) edges))
:when (seq group)]
(let [name (staging-name "edge" from-table to-table)]
(with-open [root (edge-batch allocator group)]
(with-staged-table! conn allocator name root
{:from-table from-table :to-table to-table}
#(copy-edge-group! conn from-table to-table name))))))
+383
View File
@@ -0,0 +1,383 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.debug
"In-memory Ladybug sessions for the debug graph console."
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.time :as ct]
[app.graph.ingest :as graph.ingest]
[app.graph.ladybug :as ladybug]
[app.graph.schema.nodes :as nodes]
[app.graph.sync :as graph.sync]
[app.msgbus :as mbus]
[clojure.java.io :as io]
[clojure.string :as str]
[promesa.exec.csp :as sp])
(:import
com.ladybugdb.Connection
com.ladybugdb.Database))
(set! *warn-on-reflection* true)
(def default-query
"Default console query, written to be self-explanatory in the textarea.
The `filter_*` columns carry node ids for the graph-view result filter;
the results table hides them (see `hide-filter-columns` and the
template's `renderQueryOutput`)."
(str "MATCH (s)-[r]->(t)\n"
"// WHERE some condition\n"
"RETURN label(s) AS src, s.name,\n"
" label(r) AS rel,\n"
" t.name, label(t) AS tgt,\n"
"\n"
"// filter_* columns omitted from table; these needed for graph view\n"
"s.id AS filter_src_id, t.id AS filter_tgt_id;"))
(defonce ^:private sessions
(atom {}))
(defn- session-key
[profile-id]
(str profile-id))
(defn- destroy-session!
[{:keys [conn db sync-ch msgbus]}]
(when sync-ch
(sp/close! sync-ch)
(when msgbus
(mbus/purge! msgbus [sync-ch])))
(when conn
(ex/ignoring (.close ^Connection conn)))
(when db
(ex/ignoring (.close ^Database db))))
(defn- slim-ingest-meta
"Drop full projection rows from session meta.
`build-index` needs `:nodes`/`:edges` once; keeping them in the session
duplicates the entire graph on the JVM heap for every Load."
[meta]
(update meta :projection #(select-keys % [:stats])))
(defn- format-cell
[value]
(cond
(nil? value) "NULL"
(string? value) value
:else (str value)))
(defn- format-query-result
[{:keys [columns rows truncated?]}]
{:columns (mapv str columns)
:rows (mapv (fn [row]
(mapv format-cell row))
rows)
:truncated? truncated?
:row-count (count rows)})
(defn- apply-file-change!
[conn profile-id {:keys [changes revn file-id]}]
(try
(some-> (get @sessions (session-key profile-id))
(as-> current
(when (= file-id (:file-id current))
(let [lock (:lock current)
result (locking lock
(graph.sync/apply-changes!
conn (:index current) changes revn))
sync-at (ct/now)]
(swap! sessions assoc-in [(session-key profile-id) :index]
(:index result))
(swap! sessions update-in [(session-key profile-id) :meta]
(fn [meta]
(cond-> (-> meta
(update :sync dissoc :error)
(assoc-in [:sync :last-at] sync-at)
(assoc-in [:sync :last-applied] (:applied result))
(assoc-in [:sync :last-skipped] (:skipped result)))
(seq (:applied result))
(assoc :revn (:revn result)))))
(when (seq (:skipped result))
(l/dbg :hint "graph sync skipped changes"
:file-id (str file-id)
:revn revn
:skipped (:skipped result)))))))
(catch Throwable cause
(l/wrn :hint "graph sync failed"
:file-id (str file-id)
:cause cause)
(swap! sessions assoc-in [(session-key profile-id) :meta :sync :error]
(ex-message cause)))))
(defn- start-sync-loop!
[{:keys [conn profile-id file-id] :as session}]
(if-let [msgbus (:msgbus session)]
(let [sync-ch (sp/chan :buf (sp/dropping-buffer 64))]
(mbus/sub! msgbus :topic file-id :chan sync-ch)
;; Recur ONLY while the channel is open. A bare `(recur)` after
;; `take!` returns nil would spin forever and pin this Connection
;; (and its Ladybug Database native memory) across every Load.
(sp/go-loop []
(when-let [message (sp/take! sync-ch)]
(when (= :file-change (:type message))
(apply-file-change! conn profile-id message))
(recur)))
(assoc session :sync-ch sync-ch))
session))
(defn session-info
"Return a public view of the current session for `profile-id`, if any."
[profile-id]
(when-let [{:keys [file-id meta loaded-at index]} (get @sessions (session-key profile-id))]
{:file-id file-id
:name (:name meta)
:revn (:revn meta)
:graph-revn (:revn index)
:schema-version (:schema-version meta)
:projection (:projection meta)
:sync (:sync meta)
:loaded-at (ct/format-inst loaded-at :iso)}))
(defn sync-status
"Return incremental sync status for the active session."
[profile-id]
(when-let [session (get @sessions (session-key profile-id))]
(let [{:keys [file-id meta index loaded-at]} session]
{:file-id file-id
:revn (:revn meta)
:graph-revn (:revn index)
:sync (:sync meta)
:loaded-at (ct/format-inst loaded-at :iso)})))
(defn unload-session!
"Close and discard the in-memory graph for `profile-id`."
[profile-id]
(when-let [session (get @sessions (session-key profile-id))]
(destroy-session! session))
(swap! sessions dissoc (session-key profile-id)))
(defn load-session!
"Ingest `file-id` into a new in-memory Ladybug database for `profile-id`."
[cfg profile-id file-id]
(unload-session! profile-id)
(let [^Database db (Database.)
^Connection conn (Connection. db)
msgbus (::mbus/msgbus cfg)]
(.setQueryTimeout conn 0)
(ladybug/ensure-extensions! conn)
(try
(let [meta (graph.ingest/ingest-on-connection! cfg conn file-id
:db-path ":memory:"
:skip-stats? true
:skip-validation? true)
index (graph.sync/build-index file-id (:revn meta) (:projection meta))
;; Discard projection rows after indexing — they are only needed
;; to seed the sync index and would otherwise leak heap on each Load.
meta (slim-ingest-meta meta)
session
;; :lock serializes access to the shared Connection between the
;; msgbus sync loop (writes) and HTTP handlers (reads); the Java
;; binding gives no thread-safety guarantee for one Connection.
(-> {:db db
:conn conn
:lock (Object.)
:file-id file-id
:meta meta
:index index
:msgbus msgbus
:profile-id profile-id
:loaded-at (ct/now)}
start-sync-loop!)]
(swap! sessions assoc (session-key profile-id) session)
meta)
(catch Throwable cause
(destroy-session! {:conn conn :db db :msgbus msgbus})
(throw cause)))))
(defn query-session!
"Run a read-only `statement` against the in-memory graph for `profile-id`.
The statement is bound against the live schema before it runs, so a query
naming a table or a property that does not exist reports the binder's own
message and executes nothing. The engine's read/write analysis then decides
whether it may run at all: the console is an inspection surface, and a
session graph is rebuilt from the file by Reload, so a mutation from here
would produce a graph no rebuild reproduces."
[profile-id statement]
(when (str/blank? statement)
(ex/raise :type :validation
:code :missing-query
:hint "cypher query is required"))
(if-let [{:keys [conn lock]} (get @sessions (session-key profile-id))]
(locking lock
(let [{:keys [ok? error read-only?]} (ladybug/validate-on-connection! conn statement)]
(when-not ok?
(ex/raise :type :validation
:code :graph-query-invalid
:hint error))
(when-not read-only?
(ex/raise :type :validation
:code :graph-query-not-read-only
:hint "the graph console runs read-only queries"))
(-> (ladybug/query-on-connection! conn statement)
format-query-result)))
(ex/raise :type :not-found
:code :graph-session-not-loaded
:hint "load a file graph before running queries")))
(def ^:private export-max-rows
"Row cap for graph-view export queries; far above expected per-file node
and edge counts. `:truncated` in the export signals when it was hit."
100000)
(defn- export-nodes
[conn]
(reduce
(fn [acc {:keys [table]}]
(let [stmt (str "MATCH (n:" (nodes/match-label table)
") RETURN n.id AS id, n.name AS name;")
{:keys [rows truncated?]}
(ladybug/query-on-connection! conn stmt :max-rows export-max-rows)]
(-> acc
(update :nodes into
(map (fn [[id label]]
{:id (str id) :label (str label) :table table}))
rows)
(update :truncated? #(or % truncated?)))))
{:nodes [] :truncated? false}
nodes/node-types))
(defn rel-tables
"Every relationship table in the open database, with whether it carries a
`position` property.
Read from the catalog rather than listed here, so a newly ported transform's
rel table appears in the graph view without the console being told about it."
[conn]
(for [[table] (:rows (ladybug/query-on-connection!
conn "CALL show_tables() WHERE type = 'REL' RETURN name;"
:max-rows 1000))
:let [props (->> (ladybug/query-on-connection!
conn (str "CALL table_info('" table "') RETURN *;")
:max-rows 1000)
:rows
(into #{} (map (comp str second))))]]
{:table table :position? (contains? props "position")}))
(defn- export-edges
[conn]
(reduce
(fn [acc {:keys [table position?]}]
(let [stmt (str "MATCH (a)-[r:`" table "`]->(b) "
"RETURN a.id AS source, b.id AS target, "
(if position? "r.position" "NULL") " AS position, "
"'" table "' AS rel;")
{:keys [rows truncated?]}
(ladybug/query-on-connection! conn stmt :max-rows export-max-rows)]
(-> acc
(update :edges into
(map (fn [[source target position rel]]
(cond-> {:source (str source)
:target (str target)
:rel (str rel)}
(some? position) (assoc :position position))))
rows)
(update :truncated? #(or % truncated?)))))
{:edges [] :truncated? false}
(rel-tables conn)))
(defn- bm-usage-bytes
"Buffer-manager memory in use by this session's in-memory database
(`CALL bm_info()` → [mem_limit mem_usage]); nil if the call fails."
[conn]
(ex/ignoring
(-> (ladybug/query-on-connection! conn "CALL bm_info() RETURN *;" :max-rows 1)
:rows first second)))
(defn export-graph-data!
"Export the node/edge inventory of the in-memory graph for `profile-id`
as plain data for the debug graph view. Returns nil when no session is
loaded. Queries the Ladybug database (not the sync index) so the view
reflects actual DB state, including drift."
[profile-id]
(when-let [{:keys [conn lock file-id index]} (get @sessions (session-key profile-id))]
(locking lock
(let [{:keys [nodes] nodes-truncated? :truncated?} (export-nodes conn)
{:keys [edges] edges-truncated? :truncated?} (export-edges conn)]
{:file-id (str file-id)
:revn (:revn index)
:truncated (boolean (or nodes-truncated? edges-truncated?))
:bm-bytes (bm-usage-bytes conn)
:nodes nodes
:edges edges}))))
(defn- delete-tree!
[^java.io.File file]
(when (.exists file)
(doseq [f (reverse (file-seq file))]
(.delete ^java.io.File f))))
(defn export-session-database!
"Materialize the in-memory session graph of `profile-id` as a `.lbug` file.
The console's graph is in-memory and live-synced, so it can differ from a
fresh projection of the same file — which is exactly when someone wants to
take it away and query it elsewhere. There is no \"save this database\"
primitive, so the transfer goes through Ladybug's `EXPORT DATABASE` (Parquet
per table) into a fresh on-disk database via `IMPORT DATABASE`.
Note the round trip drops table comments. Nothing in the graph is addressed
by a table comment: every table is resolved by name, so the loss costs
nothing.
Returns the path of the written database, or nil when no session is loaded.
The caller owns the file and must delete it once streamed."
[profile-id]
(when-let [{:keys [conn lock file-id]} (get @sessions (session-key profile-id))]
(let [stamp (System/nanoTime)
staging (io/file (System/getProperty "java.io.tmpdir")
(str "penpot-graph-session-" file-id "-" stamp))
db-path (str (io/file (System/getProperty "java.io.tmpdir")
(str file-id "-session-" stamp ".lbug")))]
(try
(locking lock
(ladybug/exec-on-connection!
conn [(str "EXPORT DATABASE '" (.getAbsolutePath staging)
"' (format='parquet');")]))
(ladybug/with-connection! db-path
(fn [target]
(ladybug/exec-on-connection!
target [(str "IMPORT DATABASE '" (.getAbsolutePath staging) "';")
"CHECKPOINT;"])))
db-path
(finally
(delete-tree! staging))))))
(defn- hide-filter-columns
"Drop `filter_*` columns from a query result before HTML table render;
they exist to feed node ids to the graph-view filter, not for reading.
The JSON response path keeps the full result."
[{:keys [columns rows] :as result}]
(let [idxs (vec (keep-indexed
(fn [i c] (when-not (str/starts-with? (str c) "filter_") i))
columns))]
(if (or (empty? idxs) (= (count idxs) (count columns)))
result
(assoc result
:columns (mapv (vec columns) idxs)
:rows (mapv (fn [row] (mapv (vec row) idxs)) rows)))))
(defn console-context
"Build template data for the graph debug console page."
[profile-id & {:keys [query query-result error message]}]
{:session (session-info profile-id)
:query (or query default-query)
:query-result (some-> query-result hide-filter-columns)
:error error
:message message
:default-query default-query})
+108
View File
@@ -0,0 +1,108 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.ingest
"Penpot file -> Ladybug graph projection."
(:require
[app.binfile.common :as bfc]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.types.file :as ctf]
[app.db :as db]
[app.graph.arrow :as graph.arrow]
[app.graph.ladybug :as ladybug]
[app.graph.meta :as graph.meta]
[app.graph.projection.document :as projection.document]
[app.graph.projection.transforms :as projection.transforms]
[app.graph.schema :as schema]
[app.graph.stats :as stats]
[app.srepl.helpers :as h])
(:import
com.ladybugdb.Connection
org.apache.arrow.memory.BufferAllocator))
(defn- fetch-file!
[system file-id]
(let [file-id (h/parse-uuid file-id)
file (db/run! system #(bfc/get-file % file-id :realize? true))]
(when-not file
(ex/raise :type :not-found
:code :file-not-found
:file-id (str file-id)))
(when-not (:data file)
(ex/raise :type :validation
:code :file-without-data
:hint "file has no data to project"
:file-id (str file-id)))
[file-id file]))
(defn- ingest-on-connection*!
[system ^Connection conn file-id ^BufferAllocator allocator
{:keys [db-path skip-stats? skip-validation?] :or {skip-stats? true}}]
(let [[file-id file] (fetch-file! system file-id)
db-path (or db-path (ladybug/db-path-for-file file-id))
data (:data file)]
(when-not skip-validation?
(ctf/check-file-data data))
(l/inf :hint "graph ingest"
:file-id (str file-id)
:revn (:revn file)
:db-path db-path
:schema schema/schema-version)
(let [ddl (schema/ddl-statements)
{:keys [nodes edges stats]}
(projection.document/projection-data data file)]
(ladybug/exec-on-connection! conn ddl)
(graph.arrow/load-projection! conn {:nodes nodes :edges edges} allocator)
(ladybug/exec-on-connection! conn ["CHECKPOINT;"])
(let [transforms (projection.transforms/apply-transforms! system conn data file)]
;; Written last: its presence doubles as the build-complete marker,
;; and it is what the parity consumer reads to know what is left.
(graph.meta/write! conn {:file-id file-id
:revn (:revn file)
:transform-ids (:ids transforms)})
{:file-id file-id
:revn (:revn file)
:name (or (:name data) (:name file))
:db-path db-path
:schema-version schema/schema-version
:projection {:stats stats
:nodes nodes
:edges edges}
:transforms transforms
:stats (when-not skip-stats?
(stats/summarize-connection conn))}))))
(defn ingest-on-connection!
"Project `file-id` into an already open Ladybug `conn`.
Takes an `:arrow-alloc` when the caller already owns one; otherwise it makes
a short-lived allocator around this call. A caller that opened the connection
itself should pass its own, because the allocator has to be closed *after*
the connection — see `app.graph.arrow/with-allocator!`."
[system ^Connection conn file-id & {:keys [arrow-alloc] :as opts}]
(if arrow-alloc
(ingest-on-connection*! system conn file-id arrow-alloc opts)
(graph.arrow/with-allocator!
(fn [allocator] (ingest-on-connection*! system conn file-id allocator opts)))))
(defn ingest-file!
[system file-id & {:keys [db-path reset-db? skip-stats? skip-validation?]
:or {reset-db? true}}]
(let [db-path (or db-path (ladybug/db-path-for-file (h/parse-uuid file-id)))]
(when reset-db?
(ladybug/reset-db-path! db-path))
;; Allocator outermost: Ladybug holds the staged Arrow buffers until its
;; tables are dropped, which is no later than connection close, so the
;; allocator must be closed after the connection and the database.
(graph.arrow/with-allocator!
(fn [allocator]
(ladybug/with-connection! db-path
(fn [conn]
(ingest-on-connection*! system conn file-id allocator
{:db-path db-path
:skip-stats? skip-stats?
:skip-validation? skip-validation?})))))))
+504
View File
@@ -0,0 +1,504 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.ladybug
"Ladybug access layer for graph-backed Penpot.
Uses the embedded Java API (`com.ladybugdb/lbug`)."
(:require
[app.common.exceptions :as ex]
[app.common.json :as json]
[app.graph.schema.values :as values]
[clojure.string :as str]
[datoteka.fs :as fs])
(:import
com.ladybugdb.Connection
com.ladybugdb.Database
com.ladybugdb.FlatTuple
com.ladybugdb.PreparedStatement
com.ladybugdb.QueryResult
com.ladybugdb.Value))
(set! *warn-on-reflection* true)
(defn default-graph-dir
[]
(or (System/getenv "PENPOT_GRAPH_DIR") "/tmp/penpot-graph"))
(defn db-path-for-file
[file-id]
(str (fs/path (default-graph-dir) (str file-id ".lbug"))))
(defn- memory-db-path?
[db-path]
(= db-path ":memory:"))
(defn reset-db-path!
[db-path]
(when-not (memory-db-path? db-path)
(when (fs/exists? db-path)
(fs/delete db-path))))
(defn escape-cypher-string
[s]
(-> (str s)
(str/replace "\\" "\\\\")
(str/replace "'" "\\'")))
(defn format-uuid
[id]
(str "uuid('" (str id) "')"))
(defn format-string
[s]
(str "'" (escape-cypher-string s) "'"))
(defn format-int
[n]
(str (long n)))
(defn format-number
[n]
(if (== n (long n))
(format-int n)
(str (double n))))
(defn format-json
[v]
(str "json('" (escape-cypher-string (json/encode v)) "')"))
(defn format-timestamp
"Ladybug TIMESTAMP literal of the form `timestamp('<ISO-8601 instant>')`."
[v]
(let [s (cond
(instance? java.time.Instant v)
(.toString ^java.time.Instant v)
(instance? java.util.Date v)
(.toString (.toInstant ^java.util.Date v))
(string? v)
v
:else
(str v))]
(str "timestamp('" (escape-cypher-string s) "')")))
(defn format-value
[v]
(cond
(nil? v) "NULL"
(uuid? v) (format-uuid v)
(instance? java.time.Instant v) (format-timestamp v)
(instance? java.util.Date v) (format-timestamp v)
(string? v) (format-string v)
(number? v) (format-number v)
(boolean? v) (if v "true" "false")
(keyword? v) (format-string (name v))
(map? v) (format-json v)
(coll? v) (format-json v)
:else (format-string (str v))))
(defn map-type?
"Is `ladybug-type` a MAP column?"
[ladybug-type]
(and (string? ladybug-type)
(str/starts-with? ladybug-type "MAP(")
(not (str/ends-with? ladybug-type "]"))))
(defn list-type?
"Is this a list or fixed-size array type? Checked before MAP and STRUCT,
since `STRUCT(…)[]` starts with `STRUCT(` but is a list of them."
[ladybug-type]
(and (string? ladybug-type)
(some? (re-matches #".+\[\d*\]$" ladybug-type))))
(defn struct-type?
[ladybug-type]
(and (string? ladybug-type)
(str/starts-with? ladybug-type "STRUCT(")
(not (list-type? ladybug-type))))
(declare format-typed-value)
(defn- format-typed-list
"Cypher LIST literal, elements formatted by the element type.
Handles `T[]` and the fixed-size `T[n]` alike: the size constrains the column,
not the literal."
[ladybug-type v]
(let [element (second (re-matches #"(.+?)\[\d*\]$" ladybug-type))
elems (if (or (sequential? v) (set? v)) (seq v) [v])]
(str "[" (str/join ", " (map #(format-typed-value element %) elems)) "]")))
(defn- format-struct
"Cypher STRUCT literal, `{field: value, …}`.
*Every* declared field is emitted, NULL where the value has none: a struct
literal's type is its field list, so omitting a field yields a different type
and Ladybug refuses the implicit cast (`STRUCT(m2 DOUBLE, m4 DOUBLE)` cannot
be assigned to `STRUCT(m1 …, m2 …, m3 …, m4 …)`). Penpot's layout margins are
exactly that case — a shape sets only the sides it overrides."
[ladybug-type v]
(let [fields (values/struct-fields ladybug-type)]
(str "{"
(str/join ", "
(for [[field field-type] fields
:let [fv (get v field)]]
;; Backticked for the same reason as in the DDL: a field
;; named `column` is a keyword and will not parse bare.
;; A bare NULL is typed STRING, which changes the struct's
;; type as surely as omitting the field would, so absent
;; fields get a NULL cast to their declared type.
(str "`" field "`: "
(if (nil? fv)
(str "cast(NULL, '" field-type "')")
(format-typed-value field-type fv)))))
"}")))
(defn format-typed-value
"Cypher literal for `v` in a column of `ladybug-type`.
Recursive over the type language, because the types are: a
`MAP(UUID, STRUCT(…))` needs its keys, its fields and each field's own type
honoured. `app.graph.schema.values/coerce` shapes the value first — turning a
matrix record into six doubles, a hex colour into a packed integer — so this
function only has to escape plain data.
`map-key-fn` renders the keys of a `MAP(STRING, …)`; the caller supplies it
because the right form is a property of the column, not of this function
(`app.graph.schema.contract/map-key-fn`)."
([ladybug-type v] (format-typed-value ladybug-type v nil))
([ladybug-type v map-key-fn]
(let [v (values/coerce ladybug-type v)]
(cond
(nil? v)
"NULL"
(list-type? ladybug-type)
(format-typed-list ladybug-type v)
(map-type? ladybug-type)
(let [[key-type value-type] (values/map-types ladybug-type)
entries (seq v)
format-key (if (and map-key-fn (= "STRING" key-type))
#(format-string (map-key-fn (key %)))
#(format-typed-value key-type (key %)))]
(str "map([" (str/join ", " (map format-key entries))
"], ["
(str/join ", " (map #(format-typed-value value-type (val %)) entries))
"])"))
(struct-type? ladybug-type)
(format-struct ladybug-type v)
(= ladybug-type "JSON")
(format-json v)
;; Coerce string ids from transit edge-cases into UUID literals.
(= ladybug-type "UUID")
(format-uuid v)
(= ladybug-type "TIMESTAMP")
(format-timestamp v)
:else
(format-value v)))))
(defn- ensure-semicolon
[statement]
(let [s (str/trim (str statement))]
(if (str/ends-with? s ";") s (str s ";"))))
(defn- value->clj
[^Value value]
(when-not (.isNull value)
(let [v (try
(.getValue value)
(catch Exception _
;; LIST/STRUCT values are not supported by the binding's
;; getValue (\"value_get_value\"); fall back to the textual
;; representation so console queries do not crash.
(.toString value)))]
(cond
(instance? Long v) v
(instance? Integer v) (long v)
(instance? Double v) v
:else v))))
(defn- check-success!
[^QueryResult result statement]
(when-not (.isSuccess result)
(let [err (.getErrorMessage result)]
(ex/raise :type :internal
:code :ladybug-query-failed
:hint (str "Ladybug query failed: " err)
:statement statement
:err err))))
(defn- query-columns
[^QueryResult result]
(let [ncols (.getNumColumns result)]
(vec (for [i (range ncols)]
(.getColumnName result (long i))))))
(defn- query-row
[^FlatTuple tuple ncols]
(vec (for [i (range ncols)]
(with-open [^Value value (.getValue tuple (long i))]
(value->clj value)))))
(def ^:private default-query-max-rows 200)
(defn- read-query-rows
[^QueryResult result ncols max-rows]
(loop [rows [] n 0]
(if (and (< n max-rows) (.hasNext result))
(let [row (with-open [^FlatTuple tuple (.getNext result)]
(query-row tuple ncols))]
(recur (conj rows row) (inc n)))
rows)))
(defn query-on-connection!
"Execute a Cypher query on `conn` and return tabular results.
Returns `{:columns [...] :rows [[...] ...] :truncated? bool}`."
[^Connection conn statement & {:keys [max-rows]
:or {max-rows default-query-max-rows}}]
(let [cypher (ensure-semicolon statement)]
(with-open [^QueryResult result (.query conn cypher)]
(check-success! result cypher)
(let [ncols (long (.getNumColumns result))
columns (query-columns result)
rows (read-query-rows result ncols max-rows)
total (long (.getNumTuples result))]
{:columns columns
:rows rows
:truncated? (and (pos? total) (> total (count rows)))}))))
(def ^:private default-query-timeout-ms
"0 disables query timeout (recommended for bulk COPY ingest)."
0)
(defn- scalar-value
[^Connection conn statement]
(let [cypher (ensure-semicolon statement)]
(with-open [^QueryResult result (.query conn cypher)]
(check-success! result cypher)
(when (.hasNext result)
(with-open [^FlatTuple tuple (.getNext result)]
(with-open [^Value value (.getValue tuple 0)]
(value->clj value)))))))
(defn- extension-statement-ok?
[err-msg]
(let [err (str/lower-case (or err-msg ""))]
(or (str/includes? err "already loaded")
(str/includes? err "already installed"))))
(defn- run-extension-statement!
[^Connection conn statement]
(let [cypher (ensure-semicolon statement)]
(with-open [^QueryResult result (.query conn cypher)]
(when-not (.isSuccess result)
(let [err (.getErrorMessage result)]
(when-not (extension-statement-ok? err)
(check-success! result cypher)))))))
(defn ensure-extensions!
"Install and load Ladybug extensions required by graph ingest and sync."
[^Connection conn]
(run-extension-statement! conn "INSTALL json;")
(run-extension-statement! conn "LOAD json;"))
(defn- run-statements!
[^Connection conn statements]
(doseq [statement statements]
(let [cypher (ensure-semicolon statement)]
(with-open [^QueryResult result (.query conn cypher)]
(check-success! result cypher)))))
(defn- ensure-db-path!
[db-path]
(when-not (memory-db-path? db-path)
(fs/create-dir (fs/parent db-path))))
(defn with-connection!
"Open a Ladybug connection for `db-path` and invoke `(f conn)`.
Options:
- `:query-timeout-ms` query timeout in milliseconds (default 0, disabled)
For `:memory:`, the database only lives for the duration of this call;
all reads and writes must happen inside `f`."
[db-path f & {:keys [query-timeout-ms]
:or {query-timeout-ms default-query-timeout-ms}}]
(ensure-db-path! db-path)
(let [^Database db (if (memory-db-path? db-path)
(Database.)
(Database. (str db-path)))]
(try
(let [^Connection conn (Connection. db)]
(try
(.setQueryTimeout conn (long query-timeout-ms))
(ensure-extensions! conn)
(f conn)
(finally
(.close conn))))
(finally
(.close db)))))
(defn exec-on-connection!
"Execute Cypher statements on an open Ladybug connection."
[^Connection conn statements]
(assert (sequential? statements) "statements should be a sequential collection")
(run-statements! conn statements))
;; --- prepared statements
(defn- ->param-value
"Clojure scalar → `Value` for prepared-statement binding.
This is the only `Value` constructor on the write path, so every parameter
is wrapped here. Parameters are scalars: the `Value` constructor takes no
list or map, so `MAP`, `STRUCT` and `T[]` columns stay literal-rendered
(`format-typed-value`) and the `:else` raise below means a caller tried to
bind one."
^Value [v]
(cond
(nil? v) (Value/createNull) ; no explicit type needed
(uuid? v) (Value. ^Object v) ; native UUID
(string? v) (Value. ^Object v)
(boolean? v) (Value. ^Object v)
(integer? v) (Value. ^Object (long v))
(number? v) (Value. ^Object (double v))
(keyword? v) (Value. ^Object (name v))
(instance? java.time.Instant v) ; native TIMESTAMP
(Value. ^Object v)
(instance? java.util.Date v)
(Value. ^Object (.toInstant ^java.util.Date v))
:else
(ex/raise :type :internal
:code :ladybug-unsupported-param
:hint (str "cannot bind a " (type v) " as a Ladybug parameter; "
"compound columns must be literal-rendered")
:value v)))
(defn- as-statement
"Normalize a statement to `{:cypher … :params …}`.
A bare string binds nothing, so the sync builders can convert to bound
parameters one family at a time."
[stmt]
(if (map? stmt)
(update stmt :params #(or % {}))
{:cypher stmt :params {}}))
(defn prepare-on-connection!
"Parse and bind `statement` on `conn` without executing it.
The returned `PreparedStatement` is a JNI resource: the caller closes it."
^PreparedStatement [^Connection conn statement]
(let [cypher (ensure-semicolon statement)
ps (.prepare conn cypher)]
(when-not (.isSuccess ps)
(let [err (.getErrorMessage ps)]
(.close ps)
(ex/raise :type :internal
:code :ladybug-prepare-failed
:hint (str "Ladybug prepare failed: " err)
:statement cypher
:err err)))
ps))
(defn execute-prepared!
"Bind `params` into `ps` and execute it on `conn`.
`params` keys are parameter names without the `$` (keyword or string);
values are scalars. Every bound `Value` is closed, including the ones built
before a later parameter is rejected."
[^Connection conn ^PreparedStatement ps params]
(let [vmap (java.util.HashMap.)]
(try
(doseq [[k v] params]
(.put vmap (name k) (->param-value v)))
(with-open [^QueryResult result (.execute conn ps vmap)]
(check-success! result "<prepared>"))
(finally
(run! #(.close ^Value %) (.values vmap))))))
(defn exec-prepared-on-connection!
"Prepare all statements, then execute all of them.
A parse or bind failure in *any* statement aborts the batch before the first
mutation runs — the bind-level batch gate. Statements are
`{:cypher … :params {…}}` maps or bare strings."
[^Connection conn stmts]
(assert (sequential? stmts) "statements should be a sequential collection")
(let [prepared (volatile! [])]
(try
(doseq [stmt stmts]
(let [{:keys [cypher params]} (as-statement stmt)]
(vswap! prepared conj {:ps (prepare-on-connection! conn cypher)
:params params})))
(doseq [{:keys [ps params]} @prepared]
(execute-prepared! conn ps params))
(finally
(run! #(.close ^PreparedStatement (:ps %)) @prepared)))))
(defn validate-on-connection!
"Binder gate: parse and semantic-check `statement` against the live schema,
without executing it.
Returns `{:ok? … :error … :read-only? …}`. Unlike `prepare-on-connection!`
a failure is a return value rather than a raise: the callers are gates (the
CI binder gate, the console read-only gate) that report it. `:read-only?` is
the engine's own read/write analysis."
[^Connection conn statement]
(with-open [^PreparedStatement ps (.prepare conn (ensure-semicolon statement))]
(let [ok? (.isSuccess ps)]
{:ok? ok?
:error (when-not ok? (.getErrorMessage ps))
:read-only? (when ok? (.isReadOnly ps))})))
(defn query-scalar-on-connection!
"Execute a query expected to return a single scalar value on `conn`."
[^Connection conn statement]
(scalar-value conn statement))
(defn exec!
"Execute Cypher statements against a Ladybug database.
`db-path` is either `:memory:` or a filesystem path to a `.lbug` database."
[db-path statements]
(with-connection! db-path
(fn [conn]
(exec-on-connection! conn statements))))
(defn query-scalar!
"Execute a query expected to return a single scalar value."
[db-path statement]
(with-connection! db-path
(fn [conn]
(query-scalar-on-connection! conn statement))))
(defn smoke-test!
"Run a minimal CREATE + count against Ladybug."
[& {:keys [db-path] :or {db-path ":memory:"}}]
(when-not (memory-db-path? db-path)
(reset-db-path! db-path))
(with-connection! db-path
(fn [^Connection conn]
(run-statements! conn
["CREATE NODE TABLE Person(name STRING, age INT64, PRIMARY KEY(name));"
"CREATE (:Person {name: 'Alice', age: 25});"
"CREATE (:Person {name: 'Bob', age: 30});"])
{:db-path db-path
:person-count (scalar-value conn
"MATCH (a:Person) RETURN count(a) AS c;")})))
+93
View File
@@ -0,0 +1,93 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.meta
"`GraphMeta`: the graph's own account of who built it, from what, and what
it did.
A projected graph is a cache of a file at a revision, built by a known
schema. The row records both, so a reader can decide whether to reuse the
database or rebuild it: a `schema_version` that no longer matches the
registry, or a `source_revn` behind the file's, means the cache is stale.
Ingestion is a partial port, so a graph can arrive with any subset of the
pipeline applied. `transforms` names what this build did, and the parity
consumer computes the complement and runs only that in Python. The ids cross
a language boundary as data, so they are kebab-case strings rather than
keywords and must stay byte-identical to the consumer's own list.
The row is written *last* in a build, so its presence also marks the build
complete.
Keyed by `source_file_id` rather than holding a single row: a closure graph
is a union of per-file builds, and each contributing file keeps its own
provenance."
(:require
[app.common.time :as ct]
[app.graph.ladybug :as ladybug]
[app.graph.schema.nodes :as nodes]
[clojure.string :as str])
(:import
com.ladybugdb.Connection))
(set! *warn-on-reflection* true)
(def table
"GraphMeta")
(def producer
"penpot")
(def ddl
"DDL for the provenance table."
(str "CREATE NODE TABLE `" table "` ("
"`source_file_id` UUID, "
"`producer` STRING, "
"`producer_version` STRING, "
"`schema_version` STRING, "
"`source_revn` INT64, "
"`transforms` STRING[], "
"`built_at` TIMESTAMP, "
"PRIMARY KEY (`source_file_id`));"))
(def projection-transforms
"Transform ids this backend satisfies while *projecting*, before any
transform pass runs.
A reader cares whether the result is in the graph, not how it got there. The
consumer derives `IsChildOf` from persisted `shapes` arrays in a later pass;
`app.graph.project.document/project-shape-ids` emits the edges during the
tree walk. Same id, same observable graph. The two denormalizations are the
same case."
["add-document"
"link-contained-shapes"
"denormalize-page-id"
"denormalize-component-id"])
(defn- format-transforms
[ids]
(str "[" (->> (sort (distinct ids))
(map ladybug/format-string)
(str/join ", "))
"]"))
(defn write!
"Record what this build produced for `file-id`.
`transform-ids` are the ids applied *on top of* `projection-transforms`, so
a caller only names what its transform pass did."
[^Connection conn {:keys [file-id revn transform-ids]}]
(ladybug/exec-on-connection! conn [ddl])
(ladybug/exec-on-connection!
conn
[(str "MERGE (m:`" table "` {source_file_id: " (ladybug/format-uuid file-id) "}) "
"SET m.producer = " (ladybug/format-string producer) ", "
"m.producer_version = " (ladybug/format-string (or (System/getenv "PENPOT_BUILD") "devenv")) ", "
"m.schema_version = " (ladybug/format-string nodes/schema-version) ", "
"m.source_revn = " (ladybug/format-int (or revn 0)) ", "
"m.transforms = " (format-transforms (concat projection-transforms transform-ids)) ", "
"m.built_at = " (ladybug/format-timestamp (ct/now)) ";")]))
@@ -0,0 +1,214 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.projection.document
"Project a Penpot file-data map into Ladybug nodes and structural edges.
Projects Document, Page, Component, the full shape tree (skipping the root
frame), and `IsChildOf` edges from shapes/pages/components to their parent.
Two denormalizations happen here rather than in a later pass, because the
walk already has both answers in hand and a post-ingest statement would have
to rediscover them:
- `page-id` on every shape, from the page the walk is currently in;
- `component-id` propagated from an instance head down to its descendants,
from the head context the walk carries."
(:require
[app.common.logging :as l]
[app.common.uuid :as uuid]
[app.graph.schema.nodes :as nodes]))
(def root-frame-id
uuid/zero)
(defn- document-attrs
"The Document node's attrs: the file row, minus its data blob.
`:options` is lifted out of the blob before it goes: it is file-level
configuration a consumer wants without opening `:data`."
[file data]
(-> file
(assoc :id (or (:id data) (:id file)))
(cond-> (:options data) (assoc :options (:options data)))
(dissoc :data)))
(defn- page-attrs
[page index]
(-> page
(dissoc :objects)
(cond-> (some? index) (assoc :index (long index)))))
(defn- component-attrs
[component]
(-> component
(dissoc :objects)
;; schema:component requires :path; some legacy rows omit it
(update :path #(or % ""))))
(defn- shape-table
[shape]
(nodes/table-for-type (:type shape)))
(defn denormalized-shape
"`shape` with `page-id` set and an inherited `component-id` filled in.
A shape that carries its own `component-id` keeps it; `component-ctx` only
fills the gap for descendants (see `descend-component-ctx`)."
[shape page-id component-ctx]
(cond-> (assoc shape :page-id page-id)
(and (uuid? component-ctx) (nil? (:component-id shape)))
(assoc :component-id component-ctx)))
(defn- shape-node-attrs
[table shape page-id component-ctx]
(nodes/project-attrs table (denormalized-shape shape page-id component-ctx)))
(defn descend-component-ctx
"The component context to pass to `shape`'s children.
Inheritance stops at the nearest ancestor Frame carrying a `component-id`,
and any intermediate shape that carries one is a barrier:
- a Frame with its own `component-id` becomes the new context (it is an
instance head, and its descendants belong to *it*, not to an outer head);
- any other shape carrying a `component-id` blocks inheritance below it
without being able to supply one, since only Frames are heads;
- otherwise the context passes through unchanged."
[table shape ctx]
(let [own (:component-id shape)]
(cond
(and (some? own) (= table "Frame")) own
(some? own) ::blocked
:else ctx)))
(defn- container-table?
[table]
(contains? nodes/container-tables table))
(defn- child-shape-ids
"Child ids in Penpot z-order (reversed from the stored :shapes list)."
[parent]
(when-let [shapes (:shapes parent)]
(vec (reverse shapes))))
(defn- initial-acc
[]
{:nodes {}
:edges []
:stats {:documents 0 :pages 0 :components 0 :shapes 0}})
(declare project-shape-ids)
(defn- project-shape
[objects acc table shape parent-table parent-id position page-id component-ctx]
(let [shape-id (:id shape)
acc' (-> acc
(update-in [:nodes table] (fnil conj [])
(shape-node-attrs table shape page-id component-ctx))
(update :edges conj {:from-table table
:from-id shape-id
:to-table parent-table
:to-id parent-id
:position position})
(update-in [:stats :shapes] inc))]
(if-let [child-ids (when (container-table? table)
(child-shape-ids shape))]
(project-shape-ids objects acc' table shape-id child-ids page-id
(descend-component-ctx table shape component-ctx))
acc')))
(defn- project-shape-ids
[objects acc parent-table parent-id child-ids page-id component-ctx]
(reduce
(fn [acc [position shape-id]]
(if-let [shape (get objects shape-id)]
(if-let [table (shape-table shape)]
(project-shape objects acc table shape parent-table parent-id position
page-id component-ctx)
(do
(l/wrn :hint "unsupported shape type for graph slice"
:shape-id (str shape-id)
:type (:type shape))
acc))
(do
(l/wrn :hint "missing shape in page objects"
:shape-id (str shape-id))
acc)))
acc
(map-indexed vector child-ids)))
(defn- project-page
[acc doc-id page position]
(let [page-id (:id page)
objects (:objects page)
root (get objects root-frame-id)
page-node (nodes/project-attrs "Page" (page-attrs page position))
acc' (-> acc
(update-in [:nodes "Page"] (fnil conj []) page-node)
(update :edges conj {:from-table "Page"
:from-id page-id
:to-table "Document"
:to-id doc-id
:position position})
(update-in [:stats :pages] inc))]
(if-let [top-level-ids (child-shape-ids root)]
(project-shape-ids objects acc' "Page" page-id top-level-ids page-id nil)
acc')))
(defn- project-component
[acc doc-id component position]
(if (:deleted component)
acc
(let [comp-id (:id component)
node (nodes/project-attrs "Component" (component-attrs component))]
(-> acc
(update-in [:nodes "Component"] (fnil conj []) node)
(update :edges conj {:from-table "Component"
:from-id comp-id
:to-table "Document"
:to-id doc-id
:position position})
(update-in [:stats :components] inc)))))
(defn- project-components
[acc doc-id components]
(reduce (fn [acc [position [_id component]]]
(project-component acc doc-id component position))
acc
(map-indexed vector components)))
(defn projection-data
"Build node/edge rows for projecting `data` into Ladybug.
Returns `{:nodes {table [attrs ...]} :edges [...] :stats {...}}`."
[data file]
(let [doc-id (or (:id data) (:id file))
doc-node (nodes/project-attrs "Document" (document-attrs file data))
;; `:pages` is the tab order the user sees, and `Page.index` and the
;; page's `IsChildOf.position` are that order. Child shapes are
;; reversed on the way in (`child-shape-ids`) because their stored
;; list runs bottom to top; pages have no such second ordering.
pages (seq (:pages data))
comps (seq (:components data))
acc0 (-> (initial-acc)
(update-in [:nodes "Document"] (fnil conj []) doc-node)
(assoc-in [:stats :documents] 1))
acc (cond-> acc0
(seq comps)
(project-components doc-id comps))
acc (if (empty? pages)
acc
(reduce (fn [acc [position page-id]]
(if-let [page (get-in data [:pages-index page-id])]
(project-page acc doc-id page position)
(do
(l/wrn :hint "missing page in pages-index"
:page-id (str page-id))
acc)))
acc
(map-indexed vector pages)))]
(select-keys acc [:nodes :edges :stats])))
@@ -0,0 +1,149 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.projection.transforms
"Derived graph links: edges a reader could compute from the projected
columns, materialized once at build time so a query does not have to.
Each entry in `registry` names the transform, the relationship it produces,
and the function that produces it, so adding one is a single entry and
nothing else has to be told about it."
(:require
[app.common.logging :as l]
[app.graph.ladybug :as ladybug]
[app.graph.schema.nodes :as nodes])
(:import
com.ladybugdb.Connection))
(set! *warn-on-reflection* true)
(defn- run-scalar!
[^Connection conn statement]
(or (ladybug/query-scalar-on-connection! conn statement) 0))
(defn- link-component-instances!
"`IsInstanceOf` from Frame instance heads to their Component.
Every head is linked, the main instance and any copy root alike.
`component-file` is what makes a head a head here, not `component-id` alone.
`app.common.types.component/instance-of?` requires both, and the projection
denormalizes `component-id` down the shape tree
(`app.graph.projection.document`), so on its own it no longer distinguishes a
head from a shape that merely lives inside one. `component-file` is not
denormalized and remains the head marker Penpot itself uses."
[^Connection conn]
(run-scalar! conn
(str "MATCH (f:Frame), (c:Component) "
"WHERE f.component_id = c.id "
"AND f.component_file IS NOT NULL "
"AND NOT COALESCE(c.deleted, false) "
"MERGE (f)-[:IsInstanceOf]->(c) "
"RETURN count(*);")))
(defn- shape-pair-statements
"One statement per (from, to) shape-table pair.
Ladybug cannot create a relationship bound by multiple node labels in a
single `MERGE`, a constraint inherited from Kùzu, which it forks (upstream
issue kuzudb/kuzu#5841). The loop over label pairs is that dialect
constraint, not a modelling choice."
[f]
(for [from nodes/shape-tables
to nodes/shape-tables]
(f from to)))
(defn- link-shape-refs!
"`RefersTo` from an instance shape to its homologue in the main instance,
driven by `shape-ref`."
[^Connection conn]
(reduce
(fn [total statement] (+ total (run-scalar! conn statement)))
0
(shape-pair-statements
(fn [from to]
(str "MATCH (s:" (nodes/match-label from) "), (t:" (nodes/match-label to) ") "
"WHERE s.shape_ref = t.id "
"MERGE (s)-[:RefersTo]->(t) "
"RETURN count(*);")))))
(def ^:private swap-slot-prefix "swap-slot-")
(def ^:private slot-uuid-expr
;; Ladybug `substring` is 1-indexed; 36 = RFC 4122 UUID text length.
(str "substring(touched_key, " (inc (count swap-slot-prefix)) ", 36)"))
(defn- link-swap-slots!
"`FillsSwapSlot` from a swapped-in shape to the slot it replaces.
Penpot records a component sub-shape swap as a `swap-slot-<uuid>` entry in
the *replacing* shape's `touched` set, where `<uuid>` names the replaced
slot shape in the main instance. The entries are then stripped from
`touched`, as `app.common.types.component/normal-touched-groups` does, so a
reader of `touched` sees design edits rather than swap bookkeeping.
Stripping makes this the one transform that writes a column another
transform could read. Anything reading `touched` has to run before it."
[^Connection conn]
(let [linked
(reduce
(fn [total statement] (+ total (run-scalar! conn statement)))
0
(shape-pair-statements
(fn [from to]
(str "MATCH (s:" (nodes/match-label from) ") "
"WHERE size(s.touched) > 0 "
"UNWIND s.touched AS touched_key "
"WITH s, touched_key "
"WHERE STARTS_WITH(touched_key, '" swap-slot-prefix "') "
"WITH s, CAST(" slot-uuid-expr ", 'UUID') AS slot_id "
"MATCH (t:" (nodes/match-label to) ") "
"WHERE t.id = slot_id AND s.id <> t.id "
"MERGE (s)-[r:FillsSwapSlot {slot_id: slot_id}]->(t) "
"RETURN count(r);"))))]
;; Strip unconditionally: an entry may name a slot that was garbage
;; collected, so "no edge created" does not mean "nothing to strip".
(doseq [table nodes/shape-tables]
(ladybug/exec-on-connection!
conn
[(str "MATCH (s:" (nodes/match-label table) ") "
"WHERE size(s.touched) > 0 "
"SET s.touched = list_filter(s.touched, x -> "
"NOT STARTS_WITH(x, '" swap-slot-prefix "'));")]))
linked))
(def registry
"Every transform this backend applies.
`:id` names the transform in the ingest report and the log. `:rel` names
the relationship it produces. The three registered here read disjoint
columns, so the vector order is not load-bearing. The one ordering
constraint that exists is stated on `link-swap-slots!`."
[{:id "link-component-instances" :rel :IsInstanceOf :run link-component-instances!}
{:id "link-shape-refs" :rel :RefersTo :run link-shape-refs!}
{:id "link-swap-slots" :rel :FillsSwapSlot :run link-swap-slots!}])
(defn apply-transforms!
"Apply every registered transform to an already loaded graph.
Returns `{:ids [...] :counts {...} :transforms n}`, where `:ids` names what
ran and `:counts` gives the edges each one produced."
[_system ^Connection conn _data _file]
(reduce
(fn [acc {:keys [id rel run]}]
(let [n (run conn)]
(l/inf :hint "graph transform" :transform id :edges n)
(-> acc
(update :ids conj id)
(update :counts assoc rel n)
(assoc rel n))))
{:ids [] :counts {} :transforms (count registry)}
registry))
(defn transform-ids
"Ids of every transform in the registry."
[]
(mapv :id registry))
+65
View File
@@ -0,0 +1,65 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.report
(:require
[clojure.core :as c]
[clojure.string :as str]))
(defn- println!
[& lines]
(doseq [line lines]
(println line)))
(defn- section-title
[title]
(println! (str "\n" title)
(str (apply str (repeat (count title) "─")))))
(defn- kv-line
[k v]
(format " %-14s %s" (str k ":") v))
(defn- print-node-counts
[nodes]
(doseq [[table count] (sort-by first nodes)
:when (pos? (long count))]
(println! (kv-line table count))))
(defn print-ingest!
"Pretty-print the result map returned by `app.graph.ingest/ingest-file!`."
[{:keys [file-id revn name db-path schema-version projection transforms stats]}]
(section-title "Graph ingest")
(println! (kv-line "File" (str name " (" file-id ")"))
(kv-line "Revision" revn)
(kv-line "Schema" schema-version)
(kv-line "Database" db-path))
(when-let [pstats (:stats projection)]
(section-title "Projection")
(doseq [[k v] (sort-by key pstats)]
(println! (kv-line (c/name k) v))))
(section-title "Transforms")
(println! (kv-line "Applied" (or (:transforms transforms) 0)))
(doseq [[rel count] (sort-by key (:counts transforms))]
(println! (kv-line (c/name rel) count)))
(when-let [ids (seq (:ids transforms))]
(println! (kv-line "Recorded" (str/join ", " ids))))
(when stats
(section-title "Graph counts")
(when-let [nodes (:nodes stats)]
(println! " Nodes")
(print-node-counts nodes))
(when-let [edges (:edges stats)]
(println! " Edges")
(doseq [[rel count] (sort-by key edges)
:when (pos? (long count))]
(println! (kv-line (c/name rel) count)))))
(println!)
nil)
+30
View File
@@ -0,0 +1,30 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema
"Ladybug DDL facade for the graph-backed Penpot vertical slice.
Node metadata and DDL generation live in `app.graph.schema.nodes`."
(:require
[app.graph.schema.nodes :as nodes]))
(def schema-version
nodes/schema-version)
(def container-node-tables
nodes/container-tables)
(def shape-node-tables
nodes/shape-tables)
(def node-tables
(mapv (fn [{:keys [table schema]}]
{:name table :schema schema})
nodes/node-types))
(defn ddl-statements
[]
(nodes/ddl-statements))
+136
View File
@@ -0,0 +1,136 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema.beadpot
"beadpot's graph schema, as a checked-in value.
`resources/app/graph/beadpot-schema.json` is produced by
`bp graph schema export` and read back off an empty database beadpot itself
creates, so it states what beadpot *does*: table names, column names in
order, Ladybug types, the legal (from, to) pairs of each relationship family,
and each column's default.
This namespace does not generate the DDL — that stays derived from Penpot's
own Malli schemas (`app.graph.schema.nodes`), so the backend remains
self-contained and a change to the design model shows up here as a diff to
review rather than as a silent schema change. What the manifest *is* used for
is the things Penpot cannot know on its own:
- **defaults.** beadpot writes a Pydantic field default where the file has no
such attribute, so a shape with no `blocked` key holds `false`, not NULL.
A consumer told NULL sees a missing feature where beadpot showed it a false
one. The defaults live in beadpot's models; reading them from here keeps
them from being restated in Clojure and drifting.
- **review.** `backend_tests.graph-contract-test` diffs the DDL this backend
emits against the manifest, so a divergence is a failing test naming the
column, not something a training set discovers later.
Regenerate with `bp graph schema export -o
backend/resources/app/graph/beadpot-schema.json` after any change to
beadpot's node or edge models."
(:require
[app.common.json :as json]
[clojure.java.io :as io]
[clojure.string :as str]))
(def ^:private resource-path
"app/graph/beadpot-schema.json")
(defn- read-manifest
[]
(if-let [resource (io/resource resource-path)]
(with-open [reader (io/reader resource)]
(json/read reader :key-fn keyword))
(throw (ex-info "beadpot schema manifest missing from resources"
{:path resource-path}))))
(def manifest
"The parsed manifest. Delayed so a missing resource fails where it is used."
(delay (read-manifest)))
(defn- index-tables
[tables]
(into {} (map (juxt :table identity)) tables))
(def node-tables
(delay (index-tables (:node_tables @manifest))))
(def rel-tables
(delay (index-tables (:rel_tables @manifest))))
(defn table
"The manifest entry for `table-name`, node or rel."
[table-name]
(or (get @node-tables table-name)
(get @rel-tables table-name)))
(defn columns
"Column entries for `table-name`, in beadpot's order."
[table-name]
(:columns (table table-name) []))
(defn column
"The manifest entry for one column, or nil."
[table-name column-name]
(some #(when (= column-name (:name %)) %) (columns table-name)))
(def defaults
"`table -> {column-name -> default}`, omitting columns with no default.
A JSON `null` means \"no default\": beadpot leaves the column NULL when the
attribute is unset, and so should we."
(delay
(into {}
(map (fn [[table-name entry]]
[table-name
(into {}
(keep (fn [{:keys [name default]}]
(when (some? default) [name default])))
(:columns entry))]))
(merge @node-tables @rel-tables))))
(defn column-default
"beadpot's default for `column-name` on `table-name`, or nil."
[table-name column-name]
(get-in @defaults [table-name column-name]))
(def ^:private type-aliases
"Declared type spelling → the spelling Ladybug's catalog reports.
The manifest is read back off a real database, so it holds catalog spellings;
DDL is written in whatever Ladybug accepts. `BOOLEAN` is accepted and comes
back as `BOOL`, so a literal comparison would decide the two sides disagree
about every boolean column."
{"BOOLEAN" "BOOL"})
(defn normalize-type
"A Ladybug type string in the spelling the catalog uses.
Replaces aliases anywhere in the string, so a `BOOLEAN` nested inside a
`STRUCT(...)` normalizes too."
[ladybug-type]
(when ladybug-type
(-> (reduce-kv (fn [s declared reported] (str/replace s declared reported))
ladybug-type
type-aliases)
;; STRUCT field names are written backticked (a grid cell has a field
;; called `column`); the catalog reports them bare.
(str/replace "`" ""))))
(defn typed-column-default
"beadpot's default for a column, but only when the types agree.
A default is expressed in the column's type — `[]` for a `DOUBLE[2][]`,
`{}` for a `JSON`, `[1,0,0,1,0,0]` for a `DOUBLE[6]` transform. Where this
backend still emits a different type for that column (`app.graph.schema.types`
is coarser than beadpot's encodings in a handful of places, tracked by
`bp graph schema diff`), the default would be written in a shape the column
cannot hold. So it is withheld until the types match, and the set of columns
that get defaults grows as the types converge."
[table-name column-name ladybug-type]
(when (= (normalize-type ladybug-type)
(normalize-type (:type (column table-name column-name))))
(column-default table-name column-name)))
+150
View File
@@ -0,0 +1,150 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema.contract
"Deliberate choices in Penpot's graph schema, recorded as data.
Penpot must pick a spelling and a type for every graph column. A Ladybug
column gets both once, at table creation, and neither widens afterwards. The
choices are therefore worth making deliberately and worth recording.
Three of them live here:
- `column-name` maps a Penpot key to its column. The rule is snake_case of
the key, and `renames` records every exception.
- `dropped-keys` and `per-table-dropped` name Penpot keys that deliberately
get no column.
- `type-overrides` pins the Ladybug type where the Malli-derived one
(`app.graph.schema.types`) is coarser than the column deserves.
Each entry carries its reason. A divergence from the default rule is then a
diff to review rather than a silent rename."
(:require
[app.common.json :as json]
[clojure.string :as str]))
(def ^:private renames
"Penpot key to column name, where the column is not snake_case of the key.
Keyed by the Penpot key alone: no shape type gives one of these a second
meaning, so a per-table map would only add ceremony."
{;; `bool` collides with the Ladybug type name, so the column is named after
;; the table (`Boolean`) rather than after Penpot's `:bool` shape type.
:bool-type "boolean_type"
;; The column records what the file saved, which can lag what the shape
;; tree implies. The `saved_` prefix marks it as the stored value rather
;; than a derivation.
:component-root "saved_component_root"
;; The value is a list, so the plural is accurate.
:shadow "shadows"
;; The column spells the revision number out.
:revn "revision"})
(def dropped-keys
"Penpot keys projected by the Malli registry that get no column.
Dropping is right only when the column would be dead weight for every reader
of the graph. A key a reader might learn from belongs in `unprojected-keys`
instead."
{:deleted-at
"Only non-nil for a soft-deleted file, and a deleted file is never ingested."
:pixel-grid-color
"Viewer chrome: the color of the editor's pixel grid, not design content."
:pixel-grid-opacity
"Viewer chrome, as above."})
(def unprojected-keys
"Penpot keys that should become graph columns and do not have one yet.
Distinct from `dropped-keys` on purpose: these are a debt the projection
owes, not a decision to discard data. Keeping the two apart means a new
upstream attribute cannot be quietly buried in the drop list."
{:background-blur
"Landed upstream behind a default-on flag. No column for it yet."})
(def ^:private per-table-dropped
"Keys dropped only on certain tables.
`:grids` is the standing case: Penpot's shape schema admits it on every
shape, but only a Frame ever carries one. Emitting an always-null column on
ten other tables would widen every multi-table scan for nothing."
{:grids #{"Boolean" "Circle" "Group" "Image" "Path" "Rectangle" "SVGRaw" "Text"}})
(def type-overrides
"Ladybug column type per column name, where the derived type is too coarse.
`app.graph.schema.types` derives a type from the Malli schema, which is the
right default but coarser than the column deserves in places: a Malli `:map`
becomes `JSON`, where a native Ladybug MAP or a fixed-size array lets a
consumer read a tensor row without parsing.
Only load-bearing divergences are pinned here, in the order they became
load-bearing."
{;; Must be a native MAP: a JSON blob cannot be indexed by key in Cypher, so
;; `map_keys` and `map_extract` cannot reach a single token at all.
"applied_tokens" "MAP(STRING, STRING)"
;; `grc/schema:rect` is an inline `:and` over a map, not the registered
;; `::grc/rect`, so `app.graph.schema.types` cannot recognize it by type.
;; Four doubles rather than the eight-field struct: `x1`/`y1`/`x2`/`y2` are
;; derivable from `x`/`y`/`width`/`height`, and a fixed-size array is a
;; tensor row a consumer reads without parsing.
"selrect" "DOUBLE[4]"
;; The SVG provenance attributes are typed `:map` in the shape schema on
;; purpose. Legacy files hold them as plain maps rather than as
;; `::grc/rect` and `::gmt/matrix` records, and a tighter *schema* would
;; reject those files
;; (`app.common.types.shape/schema:shape-generic-attrs`). A tighter
;; *column* is free: `app.graph.schema.values/coerce` reads either form.
"svg_viewbox" "DOUBLE[4]"
"svg_transform" "DOUBLE[6]"
;; `:fills` is an `:or` over the packed `app.common.types.fills` value and
;; a plain vector of fill maps, so the schema alone cannot say it is a
;; collection. It always is one, and a fill has enough optional shape
;; (solid, gradient, image) that JSON per element is the honest element
;; type.
"fills" "JSON[]"})
(def ^:private map-key-fns
"How to render the *keys* of a MAP column, per column.
A column name is schema, so it is snake_case. The keys inside a MAP are
values, so they keep the spelling their producer used. `applied_tokens` is
keyed by shape attribute in the camelCase form
`app.common.json/write-camel-key` produces: `strokeWidth`, not
`stroke-width`."
{"applied_tokens" json/write-camel-key})
(defn map-key-fn
"Key renderer for a MAP column. `name` unless the column says otherwise."
[column]
(get map-key-fns column name))
(defn column-name
"The graph column name for Penpot key `k`.
Default: snake_case of the key. `renames` overrides."
[k]
(or (get renames k)
(str/replace (name k) "-" "_")))
(defn drop-key?
"Should key `k` be omitted from `table`'s columns?"
[table k]
(or (contains? dropped-keys k)
(contains? (get per-table-dropped k #{}) table)))
(defn ladybug-type
"The pinned Ladybug type for `column`, or `fallback` when nothing is pinned."
[column fallback]
(get type-overrides column fallback))
+368
View File
@@ -0,0 +1,368 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema.nodes
"Single source of truth for graph node tables.
Each registry entry declares Penpot Malli sources plus projection
options (`:drop`, optional `:extra`). Derived artifacts — Ladybug
DDL, Arrow fields, validation, type dispatch — all flow from that.
This registry is the single source of the graph schema. A Ladybug column
gets its name and its type once, at table creation, and there is no
widening afterwards. Every divergence between a Penpot key and its column
is recorded in `app.graph.schema.contract`."
(:require
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.component :as ctk]
[app.common.types.file :as ctf]
[app.common.types.page :as ctp]
[app.graph.ladybug :as ladybug]
[app.graph.schema.beadpot :as beadpot]
[app.graph.schema.contract :as contract]
[app.graph.schema.projection :as projection]
[app.graph.schema.types :as types]
[clojure.string :as str]))
(def schema-version
"penpot-graph-slice-4")
(def ^:private document-projection
{:source ctf/schema:file
:drop [:data]
;; Attributes a file map carries that `ctf/schema:file` does not declare.
;;
;; They belong here rather than in that schema, even though the graph wants
;; them, because `schema:file` is on the *write* path too:
;; `app.binfile.common/update-file!` derives its UPDATE columns from a file
;; map's keys, so declaring `:backend` there made it try to write a `backend`
;; column, which the `file` table does not have — it is synthesized on read.
;; A projection `:extra` is local to the graph and cannot reach a write.
;;
;; `:options` is lifted out of `:data` before the blob is dropped
;; (`app.graph.projection.document/document-attrs`); the rest come off the file
;; map as `get-file` returns it.
:extra [:map
[:options {:optional true} [:maybe :map]]
[:backend {:optional true} [:maybe :string]]
[:comment-thread-seqn {:optional true} [:maybe :int]]
[:ignore-sync-until {:optional true} [:maybe ::ct/inst]]]})
(def ^:private page-projection
{:source ctp/schema:page
:drop [:objects]})
(def ^:private component-projection
{:source ctk/schema:component
:drop [:objects]
;; Soft-delete flag used at runtime; not in schema:component.
:extra [:map
[:deleted {:optional true} :boolean]
[:annotation {:optional true} :string]]})
(def ^:private shape-projection
{:drop [:type]})
(def ^:private shape-node-types
[{:table "Frame" :penpot-type :frame :container? true}
{:table "Group" :penpot-type :group :container? true}
{:table "Boolean" :penpot-type :bool :container? true}
{:table "SVGRaw" :penpot-type :svg-raw :container? true}
{:table "Rectangle" :penpot-type :rect}
{:table "Circle" :penpot-type :circle}
{:table "Path" :penpot-type :path}
{:table "Text" :penpot-type :text}
{:table "Image" :penpot-type :image}])
(defn- resolve-schema
[{:keys [schema source drop extra penpot-type]}]
(or schema
(when penpot-type
(projection/project-shape-schema penpot-type
{:drop drop
:extra extra}))
(projection/project-schema source
{:drop drop
:extra extra})))
(defn- shape-node-entry
[{:keys [table penpot-type container?] :as entry}]
(let [projection (-> shape-projection
(merge (:projection entry))
(assoc :penpot-type penpot-type))]
{:table table
:pk :id
:penpot-type penpot-type
:container? container?
:projection projection
:schema (resolve-schema projection)}))
(def node-types
"Ordered node registry."
(into [{:table "Document"
:pk :id
:projection document-projection
:schema (resolve-schema document-projection)}
{:table "Page"
:pk :id
:projection page-projection
:schema (resolve-schema page-projection)}
{:table "Component"
:pk :id
:projection component-projection
:schema (resolve-schema component-projection)}]
(map shape-node-entry shape-node-types)))
(def ^:private by-table
(into {} (map (juxt :table identity) node-types)))
(def ^:private by-penpot-type
(into {} (keep (fn [{:keys [penpot-type table]}]
(when penpot-type [penpot-type table]))
node-types)))
(def container-tables
(into #{} (comp (filter :container?) (map :table)) node-types))
(def shape-tables
(into [] (comp (filter :penpot-type) (map :table)) node-types))
(defn table-for-type
"Map a Penpot shape `:type` keyword to a Ladybug node table name."
[penpot-type]
(get by-penpot-type (keyword penpot-type)))
(defn node-entry
[table]
(get by-table table))
(defn projection-for
"Return the projection options map for `table`."
[table]
(:projection (node-entry table)))
(defn- entry-child-schema
"Return the value schema from a Malli map entry (`[k s]` or `[k props s]`)."
[entry]
(if (> (count entry) 2)
(nth entry 2)
(nth entry 1)))
(defn column-name
"Graph column name for projected key `k` on `table`."
[_table k]
(contract/column-name k))
(defn column-ladybug-type
"Ladybug column type for projected key `k` on `table`."
[table k]
(some (fn [entry]
(when (= k (first entry))
(contract/ladybug-type (column-name table k)
(types/ladybug-type (entry-child-schema entry)))))
(projection/schema-map-entries (:schema (node-entry table)))))
(defn column-keys
"Projected column keys for `table`, in registry order.
Keys the contract drops on this table are omitted, so the column order, the
Arrow batch, and the DDL cannot disagree about what exists."
[table]
(into []
(comp (map first)
(remove #(contract/drop-key? table %)))
(projection/schema-map-entries (:schema (node-entry table)))))
(defn columns
"Projected column names for `table`, in registry order."
[table]
(mapv #(column-name table %) (column-keys table)))
(def ^:private validate-node-fn
(memoize
(fn [table]
(let [{:keys [schema]} (node-entry table)]
(sm/check-fn schema
:type :validation
:code (keyword "graph-node-projection" (str/lower-case table))
:hint (str "invalid graph node projection for " table))))))
(defn- projection-error-hint
[table explain]
(str "invalid graph node projection for " table
(when explain
(str "\n" (sm/humanize-explain explain)))))
(defn validate-node
"Validate and return projected node attrs for `table`."
[table value]
(let [{:keys [schema]} (node-entry table)]
(try
((validate-node-fn table) value)
(catch clojure.lang.ExceptionInfo e
(let [data (ex-data e)
explain (or (::sm/explain data)
(sm/explain schema value))]
(ex/raise :type :validation
:code (keyword "graph-node-projection" (str/lower-case table))
:hint (projection-error-hint table explain)
:table table
::sm/explain explain
:cause e))))))
(defn- get-projected-attr
"The attribute under `k`, keyword or string key.
`if-some`, not `or`: `false` and `0` are values, and falling through on them
is how `opacity 0` became `nil` and then the column default."
[attrs k]
(if-some [v (get attrs k)]
v
(when (keyword? k) (get attrs (name k)))))
(defn- raise-empty-projection!
[table attrs]
(ex/raise :type :validation
:code (keyword "graph-node-projection" (str/lower-case table))
:hint (str "empty graph node projection for " table
"; columns=" (count (column-keys table))
" shape-keys=" (vec (keys attrs)))))
(defn- apply-defaults
"Fill columns the document does not set with the consumer's field defaults.
A file omits an attribute equal to its default and the consumer's models
restore it, so a shape with no `blocked` key holds `false` in the graph the
consumer builds. Writing NULL instead hands a reader a *missing* feature
where the consumer hands it a false one, so the defaults are read from the
exported manifest (`app.graph.schema.beadpot`) rather than restated here,
where they would drift.
Applied after validation: a default belongs to the graph column, not to the
Penpot schema the attrs were checked against."
[table attrs]
(reduce (fn [attrs k]
(if (contains? attrs k)
attrs
(let [column (column-name table k)]
(if-some [default (beadpot/typed-column-default
table column (column-ladybug-type table k))]
(assoc attrs k default)
attrs))))
attrs
(column-keys table)))
(defn project-attrs
"Select and validate the projected columns for `table` from `attrs`."
[table attrs]
;; `some?`, not truthiness: `false` and `0` are values. Dropping them sent
;; `opacity 0` to the column default of 1.0 — a fully transparent shape
;; projected as opaque.
(let [projected (into {}
(keep (fn [k]
(let [v (get-projected-attr attrs k)]
(when (some? v) [k v])))
(column-keys table)))]
(when (empty? projected)
(raise-empty-projection! table attrs))
(apply-defaults table (validate-node table projected))))
(defn match-label
"Cypher node label for MATCH; backtick-wrapped when required by Ladybug."
[table]
(if (#{"Group" "Boolean"} table)
(str "`" table "`")
table))
(defn cypher-property-key
"Backtick-wrapped column name for inline Cypher literals."
[table k]
(str "`" (column-name table k) "`"))
(defn column-map-key-fn
"How a MAP column of `table` renders its keys.
A MAP's keys are values, not schema, so they keep the spelling their consumer
parsed — `applied_tokens` is keyed in camelCase. Both writers need this, so it
lives next to the column's type rather than in either of them."
[table k]
(contract/map-key-fn (column-name table k)))
(defn format-column-value
"Cypher literal for `v` in column `k` of `table`.
The single place that knows both the column's Ladybug type and the contract
detail that a MAP column may render its keys differently from `name` — used
by the bulk loader's post-COPY fixups and by the incremental sync alike, so
the two cannot disagree about a value's shape."
[table k v]
(ladybug/format-typed-value (column-ladybug-type table k)
v
(column-map-key-fn table k)))
(defn- create-node-table-ddl
[{:keys [table pk]}]
(let [cols (for [k (column-keys table)]
(str "`" (column-name table k) "` " (column-ladybug-type table k)))]
(str "CREATE NODE TABLE `" table "` ("
(str/join ", " (concat cols
[(str "PRIMARY KEY (`" (column-name table pk) "`)")]))
");")))
(defn is-child-of-ddl
[]
(str "CREATE REL TABLE `IsChildOf` ("
"FROM `Page` TO `Document`, "
"FROM `Component` TO `Document`, "
(str/join ", "
(concat
(map (fn [shape]
(str "FROM `" shape "` TO `Page`"))
shape-tables)
(for [shape shape-tables
container container-tables]
(str "FROM `" shape "` TO `" container "`"))))
", `position` INT64);"))
(defn is-instance-of-ddl
"Frame instance heads → Component."
[]
"CREATE REL TABLE `IsInstanceOf` (FROM `Frame` TO `Component`);")
(defn- shape-to-shape-rel-ddl
"A rel table over the full shape × shape product.
Created up-front rather than on demand: the bulk loader must never race on
lazy table creation, and a consumer can then tell \"this producer cannot
emit that pair\" from \"this document happens to have none\"."
[rel props]
(str "CREATE REL TABLE `" rel "` ("
(str/join ", " (for [from shape-tables
to shape-tables]
(str "FROM `" from "` TO `" to "`")))
(when (seq props) (str ", " (str/join ", " props)))
");"))
(defn refers-to-ddl
"Instance shape → its homologue in the component main instance, resolved
from `shape-ref`."
[]
(shape-to-shape-rel-ddl "RefersTo" nil))
(defn fills-swap-slot-ddl
"Swapped-in shape → the slot shape it replaces."
[]
(shape-to-shape-rel-ddl "FillsSwapSlot" ["`slot_id` UUID"]))
(defn ddl-statements
[]
(-> (mapv create-node-table-ddl node-types)
(conj (is-child-of-ddl))
(conj (is-instance-of-ddl))
(conj (refers-to-ddl))
(conj (fills-swap-slot-ddl))))
@@ -0,0 +1,85 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema.projection
"Derive Ladybug node column schemas from Penpot Malli sources.
Start from the canonical schema and remove the keys that must not become
graph columns."
(:require
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.types.shape :as cts]
[malli.core :as m]))
(def ^:private malli-opts sm/default-options)
(defn- coerce-schema
"Normalize Malli sources to a compiled schema, unwrapping `:val` nodes."
[schema]
(loop [s (cond
(sm/schema? schema) schema
:else (sm/schema schema))]
(if (= :malli.core/val (sm/type s))
(recur (first (sm/children s)))
s)))
(defn- unsupported-projection-schema!
[schema]
(ex/raise :type :internal
:code :unsupported-projection-schema
:hint (str "unsupported projection schema type: "
(sm/type (coerce-schema schema)))))
(defn schema-map-entries
"Map entries for `schema`, flattening `:merge` composites."
[schema]
(let [s (coerce-schema schema)]
(or (seq (sm/entries s))
(unsupported-projection-schema! schema))))
(defn- select-projected-keys
"Project `schema` to a flat map schema, optionally dropping keys."
[schema drop-keys]
(let [s (coerce-schema schema)
keys (if (seq drop-keys)
(remove (set drop-keys) (sm/keys s))
(sm/keys s))]
(sm/select-keys s (vec keys))))
(defn shape-type-schema
"Return the compiled Penpot Malli branch for shape type `penpot-type`.
`m/entries` on the shape `:multi` yields MapEntries whose values are
compiled branch schemas (wrapped in `:val`). `m/children` returns raw
entry forms and must not be used here."
[penpot-type]
(let [kw (keyword penpot-type)
multi (sm/schema cts/schema:shape-attrs)]
(or (some (fn [entry]
(when (= kw (key entry))
(val entry)))
(m/entries multi malli-opts))
(ex/raise :type :validation
:code :unknown-shape-type
:hint (str "unknown penpot shape type: " kw)))))
(defn project-schema
"Build a graph node schema from canonical Malli `source`.
Options:
- `:drop` - keys removed from the source
- `:extra` - optional extra `[:map ...]` merged on top"
[source {:keys [drop extra]}]
(let [projected (select-projected-keys source drop)]
(if extra
(sm/merge projected (coerce-schema extra))
projected)))
(defn project-shape-schema
"Project `:drop` from the Penpot schema for `penpot-type`."
[penpot-type opts]
(project-schema (shape-type-schema penpot-type) opts))
+174
View File
@@ -0,0 +1,174 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema.types
"Map Malli schemas to Ladybug column types.
Ladybug is schema-first and strongly typed: every property key gets its type
at table-creation time, and there is no widening later. That makes this
mapping the whole of the graph's typing, and it is worth being tight — a
column typed `DOUBLE[4]` is four numbers a consumer reads as a tensor row,
where the same value as `JSON` is text somebody has to parse and trust. So
JSON is the fallback of last resort, taken only where the Malli schema
genuinely admits shapes no single column can hold.
Three groups, in the order the mapping tries them:
1. **Scalars** (`base-type->ladybug`) — the leaf Malli types.
2. **Registered composites** (`custom-type->ladybug`) — Penpot's own value
types whose *layout* is fixed even though Malli only sees a map or a
string: a matrix is six doubles, a point two, a rect four, a hex colour
one packed integer. These are named explicitly because the tight encoding
is a modelling decision, not something derivable from the schema.
3. **Structure** — collections become `T[]`, `:map-of` becomes `MAP(k, v)`,
and a closed map of scalars becomes a `STRUCT`. Anything that could be
more than one shape (a `:multi`, an `:or`, an optional-keyed map) becomes
`JSON`, because a Ladybug column cannot be two types.
Every encoding here has a matching value formatter in `app.graph.ladybug`.
The two must move together: a column type with no case there falls back to
guessing the literal from the runtime value."
(:require
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.time :as ct]
[clojure.string :as str]
[malli.core :as m]))
(def ^:private malli-opts sm/default-options)
(def ^:private base-type->ladybug
{::sm/uuid "UUID"
::sm/safe-number "DOUBLE"
::sm/safe-double "DOUBLE"
::sm/safe-int "INT64"
::sm/number "DOUBLE"
::sm/boolean "BOOLEAN"
::sm/int "INT64"
::ct/inst "TIMESTAMP"
:uuid "UUID"
:string "STRING"
:int "INT64"
:double "DOUBLE"
:float "DOUBLE"
:boolean "BOOLEAN"
:keyword "STRING"
:inst "TIMESTAMP"})
(def ^:private custom-type->ladybug
"Penpot value types with a fixed layout Malli does not express.
Fixed-size arrays are the point of each: they are dense, they need no
parsing, and a consumer can read a whole column as a tensor.
- `::gmt/matrix` — the affine transform, `[a b c d e f]`.
- `::gpt/point` — `[x y]`.
- `::grc/rect` — `[x y width height]`. `x1`/`y1`/`x2`/`y2` are dropped: they
are derivable from those four, and carrying them would double the column.
- `::clr/hex-color` — `#RRGGBB` packed as `0xRRGGBBAA`, so colours compare
and group without string handling."
{:app.common.geom.matrix/matrix "DOUBLE[6]"
:app.common.geom.point/point "DOUBLE[2]"
:app.common.geom.rect/rect "DOUBLE[4]"
:app.common.types.color/hex-color "UINT32"})
(def ^:private collection-types
#{:vector :sequential :set ::sm/vec ::sm/set ::sm/coll})
(def ^:private string-collection-types
"Registered collection schemas whose element type is not in `children`."
{::sm/set-of-strings "STRING[]"
::sm/set-of-keywords "STRING[]"
::sm/set-of-uuid "UUID[]"
::sm/vec-of-uuid "UUID[]"})
(defn- normalize-schema
"Resolve refs, but stop at a schema this namespace maps explicitly.
Order matters: `::grc/rect` derefs to an `:and` over a map, and following
that would lose the fixed-size-array encoding."
[schema]
(let [s (sm/schema schema)]
(if (and (m/-ref-schema? s)
(not (contains? custom-type->ladybug (m/type s)))
(not (contains? string-collection-types (m/type s))))
(recur (m/deref s malli-opts))
s)))
(declare ladybug-type)
(defn- entry-child
"The value schema of a Malli map entry (`[k s]` or `[k props s]`)."
[entry]
(if (> (count entry) 2) (nth entry 2) (nth entry 1)))
(defn- entry-optional?
[entry]
(and (> (count entry) 2)
(:optional (nth entry 1))))
(defn- struct-type
"`STRUCT(...)` for a closed map of scalars, or nil when JSON is the honest answer.
A struct is a fixed layout: every field present, every field a single type.
An optional key would make the column's shape depend on the row, and a nested
collection or map makes it recursive — Ladybug allows nesting, but a consumer
reading such a column gains nothing over JSON, so the line is drawn at
scalars."
[s]
(let [entries (m/entries s malli-opts)]
(when (and (seq entries)
(not-any? entry-optional? entries))
(let [fields (for [entry entries
:let [t (ladybug-type (entry-child entry))]]
(when (and t
(not= "JSON" t)
(not (str/includes? t "(")))
;; snake_case like a column name, and always
;; backtick-quoted: a grid cell has a field called
;; `column`, which is a Ladybug keyword, and an unquoted
;; one fails to parse in the DDL *and* in every literal.
;; The catalog reports them unquoted.
(str "`" (str/replace (name (key entry)) "-" "_") "` " t)))]
(when (every? some? fields)
(str "STRUCT(" (str/join ", " fields) ")"))))))
(defn ladybug-type
"Return the Ladybug column type for a Malli child schema."
[schema]
(let [s (normalize-schema schema)
t (m/type s)]
(or (base-type->ladybug t)
(custom-type->ladybug t)
(string-collection-types t)
(when (contains? collection-types t)
(when-let [child (first (m/children s malli-opts))]
(str (ladybug-type child) "[]")))
(case t
(:maybe :and) (ladybug-type (first (m/children s malli-opts)))
;; `::sm/one-of` is how Penpot spells a closed set of keywords —
;; `:blend-mode`, `:grow-type`, every `:layout-*`. One keyword, one
;; string.
(:enum ::sm/one-of) "STRING"
:map-of
(let [[key-schema value-schema] (m/children s malli-opts)]
(str "MAP(" (ladybug-type key-schema) ", "
(ladybug-type value-schema) ")"))
:map (or (struct-type s) "JSON")
;; A schema we do not recognize. If it has no children it is a leaf —
;; one of Penpot's registered keyword or enum schemas, say — and a
;; string holds it exactly. If it has children it is a composite whose
;; shape we cannot pin down, and JSON is the honest answer.
(if (empty? (m/children s malli-opts))
"STRING"
(do
(l/wrn :hint "unmapped composite malli type, defaulting to JSON"
:malli-type t)
"JSON"))))))
+202
View File
@@ -0,0 +1,202 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema.values
"Shape a Penpot value into the plain data its Ladybug column type wants.
Ladybug is strongly typed, and `app.graph.schema.types` maps Penpot's Malli
schemas onto types as tight as it can — a matrix is `DOUBLE[6]`, a rect
`DOUBLE[4]`, a colour `UINT32`, a closed map a `STRUCT`. A tight column is
only worth having if the writer actually fills it in that shape, which is
what this namespace does: it turns records and maps into the numbers, vectors
and plain maps the type names.
It deliberately stops there. Serialization belongs to the writer — Cypher
literals in `app.graph.ladybug`, Arrow vectors in `app.graph.arrow` — so that
shaping a value and writing it are separate concerns and each has one home.
The type language is the Ladybug one, read recursively: `T[]`, `T[n]`,
`MAP(k, v)`, `STRUCT(name t, …)`. Anything else is passed through."
(:require
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.types.color :as clr]
[clojure.string :as str]))
(defn- split-args
"Split a comma-separated type argument list, respecting nesting.
`\"UUID, STRUCT(a INT64, b INT64)\"` → `[\"UUID\" \"STRUCT(a INT64, b INT64)\"]`."
[s]
(loop [chars (seq s) depth 0 current (StringBuilder.) out []]
(if-let [c (first chars)]
(cond
(and (= c \,) (zero? depth))
(recur (rest chars) depth (StringBuilder.) (conj out (str/trim (str current))))
(or (= c \() (= c \[))
(recur (rest chars) (inc depth) (.append current c) out)
(or (= c \)) (= c \]))
(recur (rest chars) (dec depth) (.append current c) out)
:else
(recur (rest chars) depth (.append current c) out))
(let [last-arg (str/trim (str current))]
(cond-> out (seq last-arg) (conj last-arg))))))
(defn- parse-list
"`[element-type]` when `t` is a list or fixed-size array type, else nil.
`DOUBLE[]` and `DOUBLE[4]` are both lists of doubles as far as shaping goes;
the size only matters to the DDL."
[t]
(when-let [[_ element] (re-matches #"(.+?)\[\d*\]$" t)]
[element]))
(defn- parse-map
"`[key-type value-type]` when `t` is a MAP type, else nil."
[t]
(when-let [[_ args] (re-matches #"MAP\((.*)\)$" t)]
(let [[k v] (split-args args)]
(when (and k v) [k v]))))
(defn- parse-struct
"`[[field-name field-type] …]` when `t` is a STRUCT type, else nil.
Field names arrive backtick-quoted (see `app.graph.schema.types`). The
quoting is syntax, so it is stripped by default and re-applied by the writer —
except for the Arrow writer, which needs it kept (`keep-quotes?`)."
[t keep-quotes?]
(when-let [[_ args] (re-matches #"STRUCT\((.*)\)$" t)]
(for [arg (split-args args)
:let [idx (str/index-of arg " ")]
:when idx]
[(cond-> (subs arg 0 idx) (not keep-quotes?) (str/replace "`" ""))
(str/trim (subs arg (inc idx)))])))
(def ^:private struct-field-keys
"Field name → the Penpot keys that may hold it.
A STRUCT field name is the snake_case of the Penpot key, but a value arrives
with its original key, and some arrive from JSON with the string form. Both
are tried before giving up."
(memoize
(fn [field]
[(keyword (str/replace field "_" "-"))
(keyword field)
field
(str/replace field "_" "-")])))
(defn- struct-field
[value field]
(some (fn [k] (when (contains? value k) (get value k)))
(struct-field-keys field)))
(defn- fixed-vector
"`v` as a plain vector of numbers, for a `DOUBLE[n]` column.
Records come first because they are what a realized snapshot holds; the map
forms are what a JSON round-trip leaves behind."
[v]
(cond
(gmt/matrix? v) [(:a v) (:b v) (:c v) (:d v) (:e v) (:f v)]
(gpt/point? v) [(:x v) (:y v)]
;; A rect: four of the eight fields, the rest being derivable.
(and (map? v) (contains? v :width) (contains? v :height))
[(:x v) (:y v) (:width v) (:height v)]
(and (map? v) (contains? v :x) (contains? v :y))
[(:x v) (:y v)]
(and (map? v) (contains? v :a) (contains? v :f))
[(:a v) (:b v) (:c v) (:d v) (:e v) (:f v)]
(sequential? v) (vec v)
:else nil))
(defn- packed-color
"`#RRGGBB` as the packed integer `0xRRGGBBAA`.
Alpha defaults to opaque: the column holds a colour, and any opacity Penpot
keeps alongside it is a separate attribute."
[v]
(cond
(integer? v) v
(and (string? v) (clr/valid-hex-color? v))
(let [rgb (Long/parseLong (subs v 1) 16)]
(bit-or (bit-shift-left rgb 8) 0xFF))
:else nil))
(def struct-fields
"`[[field-name field-type] …]` for a STRUCT type, memoized.
Public because the writers need the same field list to emit a literal."
(memoize (fn [ladybug-type] (vec (parse-struct ladybug-type false)))))
(def struct-fields-quoted
"`struct-fields` with the DDL's backticks intact.
Only the Arrow writer wants this: Ladybug names a staged struct's fields from
the Arrow child names and quotes none of them, so a field whose name is a
reserved word — a layout grid cell's `column` — has to arrive already quoted
or `createArrowTable` fails outright."
(memoize (fn [ladybug-type] (vec (parse-struct ladybug-type true)))))
(def map-types
"`[key-type value-type]` for a MAP type, memoized."
(memoize (fn [ladybug-type] (parse-map ladybug-type))))
(def list-element
"Element type of a `T[]` / `T[n]` column, memoized; nil when not a list."
(memoize (fn [ladybug-type] (first (parse-list ladybug-type)))))
(declare coerce)
(defn- coerce-struct
[fields v]
(when (map? v)
(into {}
(keep (fn [[field field-type]]
(when-some [fv (struct-field v field)]
[field (coerce field-type fv)])))
fields)))
(defn coerce
"`v` as the plain data a column of `ladybug-type` holds.
Returns `nil` when the value cannot be shaped that way, which callers treat
as \"write NULL\" — a wrong shape in a strongly typed column fails the whole
load, so declining is better than guessing."
[ladybug-type v]
(cond
(nil? v) nil
(not (string? ladybug-type)) v
(= "UINT32" ladybug-type) (packed-color v)
;; Fixed-size numeric arrays are records: matrix, point, rect.
(re-matches #"DOUBLE\[\d+\]" ladybug-type) (fixed-vector v)
:else
(if-let [[element] (parse-list ladybug-type)]
(when (or (sequential? v) (set? v))
;; A set has no order, so its column would otherwise vary between
;; builds of the same file. Sorting makes it deterministic — which is
;; what lets two builds be diffed at all, and what a stable golden
;; needs. Sequential values keep their order: for `shapes` and
;; `points`, the order *is* the content.
(let [elements (mapv #(coerce element %) v)]
(if (set? v) (vec (sort-by str elements)) elements)))
(if-let [[key-type value-type] (parse-map ladybug-type)]
(when (map? v)
(into {}
(map (fn [[k mv]] [(coerce key-type k) (coerce value-type mv)]))
v))
(if-let [fields (seq (parse-struct ladybug-type false))]
(coerce-struct fields v)
v)))))
+48
View File
@@ -0,0 +1,48 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.stats
(:require
[app.graph.ladybug :as ladybug]
[app.graph.schema.nodes :as nodes]))
(defn- count-on-connection
[conn statement]
(or (ladybug/query-scalar-on-connection! conn statement) 0))
(defn- rel-table-names
"Relationship tables present in the open database.
Read from the catalog so a newly ported transform's edges are counted
without this namespace being told about it."
[conn]
(->> (ladybug/query-on-connection!
conn "CALL show_tables() WHERE type = 'REL' RETURN name;" :max-rows 1000)
:rows
(map first)))
(defn summarize-connection
"Return node/edge counts using an open Ladybug connection."
[conn]
{:nodes (into {}
(map (fn [table]
[table (count-on-connection
conn
(str "MATCH (n:" (nodes/match-label table) ") "
"RETURN count(n) AS " table "_c;"))])
(map :table nodes/node-types)))
:edges (into {}
(map (fn [rel]
[(keyword rel)
(count-on-connection
conn
(str "MATCH ()-[e:`" rel "`]->() RETURN count(e) AS c;"))]))
(rel-table-names conn))})
(defn summarize
"Return node/edge counts from the graph database."
[db-path]
(ladybug/with-connection! db-path summarize-connection))
+900
View File
@@ -0,0 +1,900 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.sync
"Incremental Ladybug graph updates from Penpot file-change events."
(:require
[app.common.logging :as l]
[app.common.uuid :as uuid]
[app.graph.ladybug :as ladybug]
[app.graph.projection.document :as projection.document]
[app.graph.schema.nodes :as nodes]
[clojure.string :as str])
(:import
com.ladybugdb.Connection))
(set! *warn-on-reflection* true)
(def ^:private supported-change-types
#{:add-obj :mod-obj :del-obj
:add-page :del-page :mod-page :mov-objects
:add-component :mod-component :del-component
:restore-component :purge-component})
(defn- shape-table
[shape]
(nodes/table-for-type (:type shape)))
(defn- build-parent-map
[edges]
(into {}
(map (fn [{:keys [from-id to-id to-table]}]
[from-id {:parent-id to-id :parent-table to-table}]))
edges))
(defn- build-children-map
[edges]
(reduce (fn [acc {:keys [from-id to-id]}]
(update acc to-id (fnil conj #{}) from-id))
{}
edges))
(defn- resolve-page-id
[shape-id parents pages]
(loop [id shape-id]
(cond
(contains? pages id) id
(get parents id) (recur (:parent-id (parents id)))
:else nil)))
(defn- node-attrs-id
[attrs]
(cond
(map? attrs) (or (:id attrs) (get attrs "id"))
(and (vector? attrs) (= 2 (count attrs)))
(let [[k v] attrs]
(when (or (= k :id) (= k "id")) v))))
(defn- table-rows
"Normalize a projection table value to a vector of attribute maps."
[nodes table]
(let [rows (or (get nodes table) (get nodes (keyword table)))]
(cond
(nil? rows) []
(map? rows) [rows]
(sequential? rows) (vec rows)
:else [])))
(defn- document-id-from-nodes
[nodes file-id]
(or (some node-attrs-id (table-rows nodes "Document"))
file-id))
(defn- page-index-entry
[attrs]
(let [id (node-attrs-id attrs)]
[id {:id id
:name (:name attrs)
:index (long (:index attrs 0))}]))
(defn- index-pages
[nodes]
(into {} (map page-index-entry (table-rows nodes "Page"))))
(defn- component-index-entry
[attrs]
(let [id (node-attrs-id attrs)]
[id {:id id
:name (:name attrs)
:deleted (boolean (:deleted attrs))}]))
(defn- index-components
[nodes]
(into {} (map component-index-entry (table-rows nodes "Component"))))
(defn- shape-index-table?
[table]
(not (contains? #{"Document" "Page" "Component"
:Document :Page :Component}
table)))
(defn- shape-index-entry
[table attrs parents pages edges]
(let [shape-id (node-attrs-id attrs)
{:keys [parent-id parent-table]} (parents shape-id)
edge (first (filter #(= shape-id (:from-id %)) edges))]
[shape-id {:id shape-id
:name (:name attrs)
:table table
:parent-id parent-id
:parent-table parent-table
:position (long (:position edge 0))
:frame-id (:frame-id attrs)
;; The projection already denormalized these; re-deriving
;; page-id from the parent chain would only be a second way to
;; get the same answer. `:component-ctx` is what later
;; `:add-obj` children inherit — it is the shape's effective
;; component-id, which loses the barrier case of a *non-Frame*
;; carrying its own `component-id` (indistinguishable once
;; denormalized). Cold projection keeps the distinction. Only a
;; graph synced across such a shape can drift, and a Reload
;; rebuilds it.
:component-ctx (:component-id attrs)
:page-id (or (:page-id attrs)
(resolve-page-id shape-id parents pages))}]))
(defn- index-shapes
[nodes edges parents pages]
(reduce
(fn [acc [table _]]
(into acc (map #(shape-index-entry table % parents pages edges)
(table-rows nodes table))))
{}
(filter (fn [[table _]] (shape-index-table? table)) nodes)))
(defn build-index
"Build a sync index from a full graph projection."
[file-id revn {:keys [nodes edges]}]
(let [doc-id (document-id-from-nodes nodes file-id)
pages (index-pages nodes)
components (index-components nodes)
parents (build-parent-map edges)
children-index (build-children-map edges)
shapes (index-shapes nodes edges parents pages)]
{:file-id file-id
:doc-id doc-id
:revn (long revn)
:pages pages
:components components
:shapes shapes
:children children-index}))
(defn- format-node-value
[table k v]
(nodes/format-column-value table k v))
(defn- create-node-statement
[table attrs]
(let [label (nodes/match-label table)
pairs (for [k (nodes/column-keys table)
:let [v (get attrs k)]
:when (some? v)]
(str (nodes/cypher-property-key table k) ": "
(format-node-value table k v)))]
(str "CREATE (:" label " {" (str/join ", " pairs) "});")))
(defn- delete-node-statement
[table shape-id]
(str "MATCH (n:" (nodes/match-label table) " {id: " (ladybug/format-uuid shape-id) "}) "
"DETACH DELETE n;"))
(defn- create-edge-statement
[{:keys [from-table from-id to-table to-id position]}]
(str "MATCH (s:" (nodes/match-label from-table) " {id: " (ladybug/format-uuid from-id) "}), "
"(p:" (nodes/match-label to-table) " {id: " (ladybug/format-uuid to-id) "}) "
"CREATE (s)-[:IsChildOf {position: " (ladybug/format-int position) "}]->(p);"))
(defn- create-instance-of-statement
"Link a Frame instance head to its Component.
No-op when the Component is absent (e.g. library component not ingested)."
[frame-id component-id]
(str "MATCH (f:Frame {id: " (ladybug/format-uuid frame-id) "}), "
"(c:Component {id: " (ladybug/format-uuid component-id) "}) "
"WHERE NOT COALESCE(c.deleted, false) "
"MERGE (f)-[:IsInstanceOf]->(c);"))
(defn- delete-instance-of-statement
[frame-id]
(str "MATCH (f:Frame {id: " (ladybug/format-uuid frame-id) "})"
"-[r:IsInstanceOf]->(:Component) "
"DELETE r;"))
(defn- instance-of-statements
"Cypher to (re)link `IsInstanceOf` after add/mod of a Frame's component-id."
[table shape-id component-id]
(when (= table "Frame")
(cond-> [(delete-instance-of-statement shape-id)]
(some? component-id)
(conj (create-instance-of-statement shape-id component-id)))))
(defn- delete-edge-statement
[{:keys [from-table from-id to-table to-id]}]
(str "MATCH (s:" (nodes/match-label from-table) " {id: " (ladybug/format-uuid from-id) "})"
"-[r:IsChildOf]->"
"(p:" (nodes/match-label to-table) " {id: " (ladybug/format-uuid to-id) "}) "
"DELETE r;"))
(defn- set-edge-position-statement
[{:keys [from-table from-id to-table to-id position]}]
(str "MATCH (s:" (nodes/match-label from-table) " {id: " (ladybug/format-uuid from-id) "})"
"-[r:IsChildOf]->"
"(p:" (nodes/match-label to-table) " {id: " (ladybug/format-uuid to-id) "}) "
"SET r.position = " (ladybug/format-int position) ";"))
(defn- set-node-attr-statement
[table shape-id attr value]
(str "MATCH (s:" (nodes/match-label table) " {id: " (ladybug/format-uuid shape-id) "}) "
"SET s." (nodes/cypher-property-key table attr) " = "
(format-node-value table attr value) ";"))
(defn- set-page-name-statement
[page-id name]
(str "MATCH (p:Page {id: " (ladybug/format-uuid page-id) "}) "
"SET p.name = " (ladybug/format-string name) ";"))
(defn- remove-node-attr-statement
"Clear a property. Ladybug has no Neo4j-style REMOVE; SET to NULL."
[table shape-id attr]
(str "MATCH (s:" (nodes/match-label table) " {id: " (ladybug/format-uuid shape-id) "}) "
"SET s." (nodes/cypher-property-key table attr) " = NULL;"))
(defn- index-add-component!
[index {:keys [id name doc-id]}]
(-> index
(assoc-in [:components id] {:id id :name name :deleted false})
(update :children update doc-id (fnil conj #{}) id)))
(defn- index-remove-component!
[index component-id]
(let [doc-id (:doc-id index)]
(-> index
(update :components dissoc component-id)
(update :children update doc-id #(disj (or % #{}) component-id)))))
(defn- set-document-revision-statement
"Set the Document's revision number.
`app.graph.schema.contract` names the column `revision`, not `revn`. The name
is produced by `nodes/cypher-property-key`, so this statement and the DDL
cannot disagree."
[doc-id revn]
(str "MATCH (d:Document {id: " (ladybug/format-uuid doc-id) "}) "
"SET d." (nodes/cypher-property-key "Document" :revn) " = "
(ladybug/format-int revn) ";"))
(defn- resolve-parent-for-add
[index {:keys [parent-id frame-id page-id]}]
(let [pid (or parent-id frame-id)]
(if (or (nil? pid) (uuid/zero? pid))
(when page-id
{:parent-id page-id :parent-table "Page"})
(if-let [shape (get-in index [:shapes pid])]
{:parent-id pid :parent-table (:table shape)}
(when (get-in index [:pages pid])
{:parent-id pid :parent-table "Page"})))))
(defn- index-add-shape!
[index {:keys [id name table parent-id parent-table position page-id
frame-id component-ctx]}]
(-> index
(assoc-in [:shapes id]
{:id id
:name name
:table table
:parent-id parent-id
:parent-table parent-table
:position position
:frame-id frame-id
:component-ctx component-ctx
:page-id page-id})
(update :children update parent-id (fnil conj #{}) id)))
(defn- index-remove-shape!
[index shape-id]
(if-let [shape (get-in index [:shapes shape-id])]
(-> index
(update :shapes dissoc shape-id)
(update :children update (:parent-id shape)
#(disj (or % #{}) shape-id))
(update :children dissoc shape-id))
index))
(defn- index-add-page!
[index {:keys [id name doc-id] page-index :index}]
(-> index
(assoc-in [:pages id] {:id id :name name :index page-index})
(update :children update doc-id (fnil conj #{}) id)))
(defn- index-move-shape!
[index shape-id {:keys [parent-id parent-table position page-id frame-id]}]
(let [old-parent (get-in index [:shapes shape-id :parent-id])]
(-> index
(assoc-in [:shapes shape-id :parent-id] parent-id)
(assoc-in [:shapes shape-id :parent-table] parent-table)
(assoc-in [:shapes shape-id :position] position)
(assoc-in [:shapes shape-id :frame-id] frame-id)
(cond-> page-id (assoc-in [:shapes shape-id :page-id] page-id))
(update :children update old-parent #(disj (or % #{}) shape-id))
(update :children update parent-id (fnil conj #{}) shape-id))))
;; --- the columns that restate parenthood
;;
;; A shape carries `parent_id` and `frame_id`, and a container carries the
;; ordered `shapes` list. All three restate what `IsChildOf` already says, and
;; the cold projection writes them from the file, so this path has to keep
;; them in step or a synced graph stops matching a rebuilt one.
(defn- shape-parent-id
"The `parent_id` a shape's own column holds.
A top-level shape's parent in the file is the page's root frame, which the
graph does not materialize, so `IsChildOf` points at the Page while the
column holds `uuid/zero`."
[parent-id parent-table]
(if (= "Page" parent-table) uuid/zero parent-id))
(defn- frame-id-under
"The `frame_id` a shape gets when its parent is `parent-id`.
Penpot's rule, from `app.common.files.changes` `:mov-objects`: the parent
itself when the parent is a Frame, the parent's own frame otherwise."
[index parent-id parent-table]
(cond
(= "Page" parent-table) uuid/zero
(= "Frame" parent-table) parent-id
:else (get-in index [:shapes parent-id :frame-id] uuid/zero)))
(defn- frame-id-updates
"`[shape-id frame-id]` for a moved shape and everything that follows it.
A Frame keeps its descendants pointing at itself, so the walk stops there.
Any other shape carries its subtree onto the new frame."
[index shape-id frame-id]
(into [[shape-id frame-id]]
(when (not= "Frame" (get-in index [:shapes shape-id :table]))
(mapcat #(frame-id-updates index % frame-id)
(get-in index [:children shape-id] #{})))))
(defn- child-shapes-value
"A container's stored `shapes` list, rebuilt from the index.
`IsChildOf.position` counts from the last entry of that list
(`app.graph.projection.document/child-shape-ids` reverses it), so reversing the
children ordered by position gives the list back."
[index parent-id]
(->> (get-in index [:children parent-id] #{})
(sort-by #(get-in index [:shapes % :position] 0))
reverse
vec))
(defn- insert-position
"The graph position the lowest of `k` shapes takes when they are inserted
into a parent that already holds `n-before` children.
A container's stored `:shapes` list runs bottom to top, and the graph
numbers children in Penpot z-order, so the two run opposite ways. An append
to the stored list, which is what `:add-obj` does without an `:index`, is
therefore position 0 and pushes every sibling up by one. The block occupies
the result and the `k - 1` positions above it, the first shape highest."
[n-before {:keys [index]} after-position]
(cond
(some? after-position) (long after-position)
(some? index) (max 0 (- n-before (long index)))
:else 0))
(defn- renumber-siblings
"Shift `parent-id`'s children at or above `from` by `delta`.
Returns `[index statements]`. `except` names children the caller is placing
itself."
[index parent-id parent-table from delta except]
(reduce
(fn [[idx stmts] child-id]
(let [pos (get-in idx [:shapes child-id :position])]
(if (and (some? pos) (not (contains? except child-id)) (>= (long pos) (long from)))
(let [pos' (+ (long pos) (long delta))]
[(assoc-in idx [:shapes child-id :position] pos')
(conj stmts (set-edge-position-statement
{:from-table (get-in idx [:shapes child-id :table])
:from-id child-id
:to-table parent-table
:to-id parent-id
:position pos'}))])
[idx stmts])))
[index []]
(vec (get-in index [:children parent-id] #{}))))
(defn- set-children-statements
"Refresh the `shapes` column of every container in `parent-ids`.
A Page has no such column: its top-level shapes hang off a root frame the
graph never materializes."
[index parent-ids]
(into []
(comp (distinct)
(keep (fn [parent-id]
(let [table (get-in index [:shapes parent-id :table])]
(when (contains? nodes/container-tables table)
(set-node-attr-statement
table parent-id :shapes
(child-shapes-value index parent-id)))))))
parent-ids))
(defn- mov-object-ids
[shapes]
(let [coll (cond
(nil? shapes) []
(sequential? shapes) shapes
(uuid? shapes) [shapes]
(map? shapes) (if-let [id (or (:id shapes) (get shapes "id"))]
[id]
[])
:else [])]
(into []
(keep (fn [shape]
(when shape
(if (uuid? shape) shape (:id shape)))))
coll)))
(defn- detach-shape
"Take `shape-id` out of its current parent and close the gap it leaves.
Returns `[index statements]`. The edge itself is left alone: the caller
either replaces it or deletes it."
[index shape-id]
(let [{:keys [parent-id parent-table position]} (get-in index [:shapes shape-id])
index (update-in index [:children parent-id] #(disj (or % #{}) shape-id))
[index stmts] (renumber-siblings index parent-id parent-table
(inc (long (or position 0))) -1 #{})]
[(assoc-in index [:shapes shape-id :position] nil) stmts]))
(defn- apply-mov-objects
[index {:keys [shapes page-id] :as change}]
(let [shape-ids (mov-object-ids shapes)
parent (resolve-parent-for-add index
(assoc change
:frame-id (:parent-id change)
:page-id page-id))]
(cond
(empty? shape-ids)
{:index index :statements [] :applied? true}
(not parent)
{:index index :statements [] :applied? false :reason :missing-parent}
:else
(let [parent-id (:parent-id parent)
parent-table (:parent-table parent)
page-id' (or page-id
(when (= parent-table "Page") parent-id)
(get-in index [:shapes (first shape-ids) :page-id]))
known (filterv #(get-in index [:shapes %]) shape-ids)
old-parents (mapv #(get-in index [:shapes % :parent-id]) known)
;; Penpot removes the shapes from wherever they were, then inserts
;; the block into the target, so the target's width is measured
;; after the removals.
[index detach-stmts]
(reduce (fn [[idx stmts] shape-id]
(let [[idx' s] (detach-shape idx shape-id)]
[idx' (into stmts s)]))
[index []]
known)
n-before (count (get-in index [:children parent-id] #{}))
after-pos (get-in index [:shapes (:after-shape change) :position])
lowest (insert-position n-before change after-pos)
k (count known)
[index shift-stmts]
(renumber-siblings index parent-id parent-table lowest k #{})]
(loop [index index
statements (into detach-stmts shift-stmts)
entries (map-indexed vector known)]
(if-let [[offset shape-id] (first entries)]
(let [shape (get-in index [:shapes shape-id])
position (+ lowest (- k 1 (long offset)))
frame-id (frame-id-under index parent-id parent-table)
frame-writes (frame-id-updates index shape-id frame-id)
edge {:from-table (:table shape)
:from-id shape-id
:to-table parent-table
:to-id parent-id
:position position}
moved? (not= parent-id (:parent-id shape))
statements (-> statements
(cond-> moved?
(conj (delete-edge-statement
{:from-table (:table shape)
:from-id shape-id
:to-table (:parent-table shape)
:to-id (:parent-id shape)})))
(conj (if moved?
(create-edge-statement edge)
(set-edge-position-statement edge))))
;; The shape's own columns restate the edge, and the frame
;; follows the whole subtree the shape carries with it.
statements (if-not moved?
statements
(into (conj statements
(set-node-attr-statement
(:table shape) shape-id :parent-id
(shape-parent-id parent-id parent-table)))
(map (fn [[sid fid]]
(set-node-attr-statement
(get-in index [:shapes sid :table])
sid :frame-id fid)))
frame-writes))
index (index-move-shape! index shape-id
{:parent-id parent-id
:parent-table parent-table
:position position
:frame-id frame-id
:page-id page-id'})
index (reduce (fn [idx [sid fid]]
(assoc-in idx [:shapes sid :frame-id] fid))
index
frame-writes)]
(recur index statements (rest entries)))
{:index index
:statements (into statements
(set-children-statements index (conj old-parents parent-id)))
:applied? true}))))))
(defn- index-remove-page!
[index page-id]
(let [doc-id (:doc-id index)]
(-> index
(update :pages dissoc page-id)
(update :children update doc-id #(disj (or % #{}) page-id))
(update :children dissoc page-id))))
(defn- mod-attrs-for-table
[table]
(disj (set (nodes/column-keys table)) :id))
(defn- apply-add-obj
[index change]
(let [{:keys [id obj page-id]} change
table (shape-table obj)]
(if-not table
{:index index :statements [] :applied? false :reason :unsupported-shape-type}
(let [parent (resolve-parent-for-add index change)]
(if-not parent
{:index index :statements [] :applied? false :reason :missing-parent}
(let [parent-id (:parent-id parent)
parent-table (:parent-table parent)
n-before (count (get-in index [:children parent-id] #{}))
position (insert-position n-before change nil)
[index shift-stmts]
(renumber-siblings index parent-id parent-table position 1 #{})
;; The same denormalizations the cold projection performs, so
;; a live-synced graph and a rebuilt one carry equal columns.
resolved-page-id
(or page-id
(when (= parent-table "Page") parent-id)
(get-in index [:shapes parent-id :page-id]))
parent-ctx (get-in index [:shapes parent-id :component-ctx])
shape (projection.document/denormalized-shape
(assoc obj :id id) resolved-page-id parent-ctx)
attrs (nodes/project-attrs table shape)
edge {:from-table table
:from-id id
:to-table parent-table
:to-id parent-id
:position position}
stmts (-> shift-stmts
(conj (create-node-statement table attrs))
(conj (create-edge-statement edge))
(into (instance-of-statements table id (:component-id attrs))))
index' (index-add-shape! index
{:id id
:name (:name attrs)
:table table
:parent-id parent-id
:parent-table parent-table
:position position
:frame-id (:frame-id attrs)
:component-ctx (projection.document/descend-component-ctx
table shape parent-ctx)
:page-id resolved-page-id})]
{:index index'
:statements (into stmts (set-children-statements index' [parent-id]))
:applied? true}))))))
(defn- apply-mod-obj
[index {:keys [id operations]}]
(if-let [shape (get-in index [:shapes id])]
(let [table (:table shape)
syncable (mod-attrs-for-table table)
set-ops (filter #(and (= :set (:type %))
(contains? syncable (:attr %)))
operations)]
(if (empty? set-ops)
{:index index :statements [] :applied? false :reason :unsupported-operations}
(let [updates (into {} (map (juxt :attr :val) set-ops))
statements
(into (vec (for [[attr value] updates]
(set-node-attr-statement table id attr value)))
;; Relink when component-id is among the synced attrs.
(when (contains? updates :component-id)
(instance-of-statements table id (:component-id updates))))
index' (reduce (fn [idx [attr value]]
(assoc-in idx [:shapes id attr] value))
index
updates)]
{:index index'
:statements statements
:applied? true})))
{:index index :statements [] :applied? false :reason :missing-shape}))
(defn- delete-order-deepest-first
[children root-id]
(letfn [(post-order [id]
(into (mapcat post-order (get children id #{}))
[id]))]
(post-order root-id)))
(defn- apply-del-obj
[index {:keys [id]}]
(if-let [root (get-in index [:shapes id])]
(let [to-delete (delete-order-deepest-first (:children index) id)
statements
(vec (mapcat (fn [shape-id]
(let [{:keys [table parent-id parent-table]}
(get-in index [:shapes shape-id])]
[(delete-edge-statement
{:from-table table
:from-id shape-id
:to-table parent-table
:to-id parent-id})
(delete-node-statement table shape-id)]))
to-delete))
index' (reduce index-remove-shape! index to-delete)
;; Only the deleted subtree's own parent survives to be renumbered:
;; every other parent in `to-delete` goes with it.
[index' shift-stmts]
(renumber-siblings index' (:parent-id root) (:parent-table root)
(inc (long (or (:position root) 0))) -1 #{})]
{:index index'
:statements (-> statements
(into shift-stmts)
(into (set-children-statements index' [(:parent-id root)])))
:applied? true})
;; Penpot emits one :del-obj per selected shape; an earlier change in the
;; same batch may have already removed this node (e.g. parent + child).
{:index index :statements [] :applied? true}))
(defn- apply-add-page
[index {:keys [id name page]}]
(let [page-id (or id (:id page))
page (or page {:id page-id :name name})
page (nodes/project-attrs "Page" {:id page-id
:name (or (:name page) "Page")
:index (count (:pages index))})
doc-id (:doc-id index)
position (count (:pages index))
edge {:from-table "Page"
:from-id page-id
:to-table "Document"
:to-id doc-id
:position position}]
{:index (index-add-page! index
{:id page-id
:name (:name page)
:index (:index page)
:doc-id doc-id})
:statements [(create-node-statement "Page" page)
(create-edge-statement edge)]
:applied? true}))
(defn- apply-del-page
[index {:keys [id]}]
(if (get-in index [:pages id])
(let [shape-ids (into #{}
(comp (filter #(= id (get-in index [:shapes % :page-id])))
(filter #(= "Page" (get-in index [:shapes % :parent-table]))))
(keys (:shapes index)))
del-shapes
(reduce (fn [acc shape-id]
(let [result (apply-del-obj acc {:type :del-obj :id shape-id})]
(if (:applied? result)
(-> acc
(assoc :index (:index result))
(update :statements into (:statements result)))
acc)))
{:index index :statements []}
shape-ids)
statements
(conj (:statements del-shapes)
(delete-edge-statement {:from-table "Page"
:from-id id
:to-table "Document"
:to-id (:doc-id index)})
(delete-node-statement "Page" id))]
{:index (-> (:index del-shapes) (index-remove-page! id))
:statements statements
:applied? true})
{:index index :statements [] :applied? false :reason :missing-page}))
(defn- apply-mod-page
[index {:keys [id name]}]
(if (and (string? name) (get-in index [:pages id]))
{:index (assoc-in index [:pages id :name] name)
:statements [(set-page-name-statement id name)]
:applied? true}
{:index index :statements [] :applied? false :reason :unsupported-page-change}))
(defn- component-syncable-attrs
"Projected Component columns that sync may SET (everything but :id)."
[]
(disj (set (nodes/column-keys "Component")) :id))
(defn- component-attrs-from-change
"Build CREATE attrs for `:add-component` (objects are not projected)."
[{:keys [id name path main-instance-id main-instance-page
annotation variant-id variant-properties]}]
(cond-> {:id id
:name (or name "Component")
:path (or path "")
:main-instance-id main-instance-id
:main-instance-page main-instance-page}
(some? annotation) (assoc :annotation annotation)
(some? variant-id) (assoc :variant-id variant-id)
(seq variant-properties) (assoc :variant-properties variant-properties)))
(defn- apply-add-component
[index {:keys [id] :as change}]
(if (get-in index [:components id])
{:index index :statements [] :applied? true}
(let [doc-id (:doc-id index)
position (count (:components index))
attrs (nodes/project-attrs "Component" (component-attrs-from-change change))
edge {:from-table "Component"
:from-id id
:to-table "Document"
:to-id doc-id
:position position}]
{:index (index-add-component! index
{:id id
:name (:name attrs)
:doc-id doc-id})
:statements [(create-node-statement "Component" attrs)
(create-edge-statement edge)]
:applied? true})))
(defn- apply-mod-component
"Update projected Component attrs from a `:mod-component` change.
Nil optional values clear the property (Penpot dissocs them). `:objects`
is never projected — shape trees live on pages."
[index {:keys [id] :as change}]
(let [syncable (component-syncable-attrs)
sets (into {}
(keep (fn [[k v]]
(when (and (contains? syncable k) (some? v))
[k v])))
(dissoc change :type :id :objects))
removes (into []
(keep (fn [[k v]]
(when (and (contains? syncable k) (nil? v))
k)))
(dissoc change :type :id :objects))
stmts (into (mapv (fn [[k v]]
(set-node-attr-statement "Component" id k v))
sets)
(map #(remove-node-attr-statement "Component" id %) removes))
index' (if (get-in index [:components id])
(cond-> index
(contains? sets :name)
(assoc-in [:components id :name] (:name sets)))
(assoc-in index [:components id]
{:id id
:name (:name sets)
:deleted false}))]
(if (empty? stmts)
{:index index :statements [] :applied? true}
{:index index' :statements stmts :applied? true})))
(defn- apply-del-component
[index {:keys [id skip-undelete?]}]
(cond
(not (get-in index [:components id]))
{:index index :statements [] :applied? true}
skip-undelete?
{:index (index-remove-component! index id)
:statements [(delete-edge-statement {:from-table "Component"
:from-id id
:to-table "Document"
:to-id (:doc-id index)})
(delete-node-statement "Component" id)]
:applied? true}
:else
{:index (assoc-in index [:components id :deleted] true)
:statements [(set-node-attr-statement "Component" id :deleted true)]
:applied? true}))
(defn- apply-restore-component
[index {:keys [id page-id]}]
(let [stmts (cond-> [(set-node-attr-statement "Component" id :deleted false)]
page-id
(conj (set-node-attr-statement "Component" id :main-instance-page page-id)))
index (if (get-in index [:components id])
(-> index
(assoc-in [:components id :deleted] false)
(cond-> page-id
(assoc-in [:components id :main-instance-page] page-id)))
(assoc-in index [:components id]
{:id id :name nil :deleted false}))]
{:index index :statements stmts :applied? true}))
(defn- apply-purge-component
[index {:keys [id]}]
(if-not (get-in index [:components id])
;; Still attempt delete in case the node exists but was not indexed.
{:index index
:statements [(delete-edge-statement {:from-table "Component"
:from-id id
:to-table "Document"
:to-id (:doc-id index)})
(delete-node-statement "Component" id)]
:applied? true}
{:index (index-remove-component! index id)
:statements [(delete-edge-statement {:from-table "Component"
:from-id id
:to-table "Document"
:to-id (:doc-id index)})
(delete-node-statement "Component" id)]
:applied? true}))
(defn- apply-change
[index change]
(case (:type change)
:add-obj (apply-add-obj index change)
:mod-obj (apply-mod-obj index change)
:del-obj (apply-del-obj index change)
:add-page (apply-add-page index change)
:del-page (apply-del-page index change)
:mod-page (apply-mod-page index change)
:mov-objects (apply-mov-objects index change)
:add-component (apply-add-component index change)
:mod-component (apply-mod-component index change)
:del-component (apply-del-component index change)
:restore-component (apply-restore-component index change)
:purge-component (apply-purge-component index change)
{:index index :statements [] :applied? false :reason :unsupported-type}))
(defn apply-changes!
"Apply Penpot `changes` to an open Ladybug `conn` and return the updated index.
Returns `{:index ... :revn ... :applied [...] :skipped [...]}`."
[^Connection conn index changes revn]
(when (> (long revn) (:revn index))
(l/wrn :hint "graph sync revn gap"
:file-id (str (:file-id index))
:index-revn (:revn index)
:change-revn revn))
(loop [index index
applied []
skipped []
stmts []
changes (seq changes)]
(if-let [change (first changes)]
(let [{:keys [index statements applied? reason]}
(apply-change index change)]
(recur index
(cond-> applied applied? (conj (:type change)))
(cond-> skipped (not applied?) (conj {:type (:type change) :reason reason}))
(cond-> stmts applied? (into statements))
(rest changes)))
(let [final-stmts (cond-> stmts
(and (seq applied) (:doc-id index))
(conj (set-document-revision-statement (:doc-id index) revn)))
index' (if (seq applied)
(assoc index :revn (long revn))
index)]
(when (seq final-stmts)
(ladybug/exec-on-connection! conn final-stmts))
{:index index'
:revn (if (seq applied) (long revn) (:revn index'))
:applied applied
:skipped skipped}))))
(defn supported-change?
[change]
(contains? supported-change-types (:type change)))
+296 -19
View File
@@ -13,6 +13,7 @@
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.features :as cfeat]
[app.common.json :as json]
[app.common.logging :as l]
[app.common.pprint :as pp]
[app.common.time :as ct]
@@ -21,6 +22,7 @@
[app.config :as cf]
[app.db :as db]
[app.features.file-migrations :as feat.fmig]
[app.http.access-token :as actoken]
[app.http.session :as session]
[app.rpc.commands.auth :as auth]
[app.rpc.commands.files-create :refer [create-file]]
@@ -33,6 +35,7 @@
[app.storage.tmp :as tmp]
[app.util.template :as tmpl]
[cuerdas.core :as str]
[datoteka.fs :as fs]
[datoteka.io :as io]
[emoji.core :as emj]
[integrant.core :as ig]
@@ -57,6 +60,7 @@
::yres/body (-> (io/resource "app/templates/debug.tmpl")
(tmpl/render {:version (:full cf/version)
:profile profile
:graph-enabled (contains? cf/flags :graph)
:current-clock ct/*clock*
:current-offset (if offset
(ct/format-duration offset)
@@ -330,6 +334,226 @@
"content-disposition" (str "attachmen; filename=" (first file-ids) ".penpot")}}))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GRAPH (flag: :graph)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; `app.graph.*` resolves at call time, never at the top of this namespace.
;; `app.graph.ladybug` imports `com.ladybugdb.*`, so requiring it links the
;; Ladybug native library into the JVM, and this namespace loads on every
;; backend boot. The routes below are registered only under the `:graph` flag,
;; so with the flag off nothing resolves and no native code loads.
(defn- graph-export-file
"Path of a freshly projected graph for `file-id`."
[cfg file-id]
(let [ingest-file! (requiring-resolve 'app.graph.ingest/ingest-file!)
{:keys [db-path]} (ingest-file! cfg file-id :skip-stats? true)]
(when-not (fs/exists? db-path)
(ex/raise :type :internal
:code :graph-file-not-found
:hint "graph database file missing after ingest"
:file-id (str file-id)
:db-path db-path))
db-path))
(defn- graph-export-session
"Path of a snapshot of the caller's live in-memory graph for `file-id`."
[profile-id file-id]
(let [session-info (requiring-resolve 'app.graph.debug/session-info)
export-session-database! (requiring-resolve 'app.graph.debug/export-session-database!)
info (session-info profile-id)]
(when-not info
(ex/raise :type :not-found
:code :graph-session-not-loaded
:hint "no in-memory graph is loaded; load one first, or use source=file"))
(when-not (= file-id (:file-id info))
(ex/raise :type :validation
:code :graph-session-file-mismatch
:hint "the loaded session holds a different file"
:requested (str file-id)
:loaded (str (:file-id info))))
(export-session-database! profile-id)))
(defn graph-export-handler
"Stream a Ladybug `.lbug` database for a file.
`source=file` (default) projects the file afresh from the database — the
reproducible artifact. `source=session` snapshots the caller's live
in-memory console graph instead, which live-sync may have moved away from a
fresh projection; taking that away to query it elsewhere is the whole point
of asking for it. Synchronous on each request."
[cfg {:keys [params] :as request}]
(let [file-id (some-> params :file-id parse-uuid)
source (or (some-> params :source str/lower) "file")]
(when-not file-id
(ex/raise :type :validation
:code :missing-arguments
:hint "missing file-id"))
(when-not (contains? #{"file" "session"} source)
(ex/raise :type :validation
:code :invalid-arguments
:hint "source must be 'file' or 'session'"
:source source))
(let [session? (= "session" source)
db-path (if session?
(graph-export-session (::session/profile-id request) file-id)
(graph-export-file cfg file-id))]
{::yres/status 200
;; A session export is a temp file this request owns; deleting it on
;; close would race the streaming body, so it is left for the OS temp
;; sweep. A file export is the canonical per-file database and is meant
;; to persist.
::yres/body (io/input-stream db-path)
::yres/headers {"content-type" "application/octet-stream"
"content-disposition"
(str "attachment; filename=" file-id
(when session? "-session") ".lbug")}})))
(defn- graph-console-response
[data]
{::yres/status 200
::yres/headers {"content-type" "text/html; charset=utf-8"
"x-robots-tag" "noindex"}
::yres/body (-> (io/resource "app/templates/graph-console.tmpl")
(tmpl/render (assoc data :version (:full cf/version))))})
(defn graph-console-handler
[_cfg {:keys [::session/profile-id]}]
(let [console-context (requiring-resolve 'app.graph.debug/console-context)]
(graph-console-response (console-context profile-id))))
(defn graph-load-handler
[cfg {:keys [params ::session/profile-id]}]
(let [file-id (some-> (:file-id params) parse-uuid)
load-session! (requiring-resolve 'app.graph.debug/load-session!)]
(when-not file-id
(ex/raise :type :validation
:code :missing-arguments
:hint "missing file-id"))
(load-session! cfg profile-id file-id)
{::yres/status 302
::yres/headers {"location" "/dbg/graph"}}))
(defn graph-unload-handler
[_cfg {:keys [::session/profile-id]}]
((requiring-resolve 'app.graph.debug/unload-session!) profile-id)
{::yres/status 302
::yres/headers {"location" "/dbg/graph"}})
(defn graph-reload-handler
"Re-ingest the currently loaded file into the in-memory graph session."
[cfg {:keys [::session/profile-id]}]
(let [session-info (requiring-resolve 'app.graph.debug/session-info)
load-session! (requiring-resolve 'app.graph.debug/load-session!)]
(if-let [file-id (some-> (session-info profile-id) :file-id)]
(do
(load-session! cfg profile-id file-id)
{::yres/status 302
::yres/headers {"location" "/dbg/graph"}})
(ex/raise :type :not-found
:code :graph-session-not-loaded
:hint "load a file graph before reloading"))))
(defn graph-sync-status-handler
[_cfg {:keys [::session/profile-id]}]
(if-let [status ((requiring-resolve 'app.graph.debug/sync-status) profile-id)]
{::yres/status 200
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (t/encode-str status {:type :json-verbose})}
{::yres/status 404
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (t/encode-str {:error "no-session"} {:type :json-verbose})}))
(defn graph-data-handler
"Export the in-memory session graph as plain JSON (not transit) for the
G6 graph view embedded in the console page."
[_cfg {:keys [::session/profile-id]}]
(if-let [data ((requiring-resolve 'app.graph.debug/export-graph-data!) profile-id)]
{::yres/status 200
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (json/encode data)}
{::yres/status 404
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (json/encode {:error "no-session"})}))
(def ^:private sql:graph-files
"select t.id as team_id, t.name as team_name,
p.id as project_id, p.name as project_name,
f.id as file_id, f.name as file_name
from team as t
join team_profile_rel as tpr on (tpr.team_id = t.id)
join project as p on (p.team_id = t.id)
join file as f on (f.project_id = p.id)
where tpr.profile_id = ?
and t.deleted_at is null
and p.deleted_at is null
and f.deleted_at is null
order by t.name, p.name, f.name
limit 500")
(defn- graph-files-tree
[rows]
(->> (group-by (juxt :team-id :team-name) rows)
(mapv (fn [[[team-id team-name] team-rows]]
{:id (str team-id)
:name team-name
:projects
(->> (group-by (juxt :project-id :project-name) team-rows)
(mapv (fn [[[project-id project-name] project-rows]]
{:id (str project-id)
:name project-name
:files (mapv (fn [{:keys [file-id file-name]}]
{:id (str file-id) :name file-name})
project-rows)}))
(sort-by :name)
(vec))}))
(sort-by :name)
(vec)))
(defn graph-files-handler
"List teams -> projects -> files reachable by the current profile, as
plain JSON for the graph console file tree."
[{:keys [::db/pool]} {:keys [::session/profile-id]}]
(let [rows (db/exec! pool [sql:graph-files profile-id])]
{::yres/status 200
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (json/encode {:teams (graph-files-tree rows)})}))
(defn- json-request?
[request]
(some-> request
(yreq/get-header "accept")
(str/includes? "application/json")))
(defn graph-query-handler
[_cfg {:keys [params ::session/profile-id] :as request}]
(let [query (:query params)
query-session! (requiring-resolve 'app.graph.debug/query-session!)
console-context (requiring-resolve 'app.graph.debug/console-context)]
(try
(let [result (query-session! profile-id query)]
(if (json-request? request)
{::yres/status 200
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (t/encode-str {:query query
:query-result result}
{:type :json-verbose})}
(graph-console-response (console-context profile-id
:query query
:query-result result))))
(catch Throwable e
(let [error (or (:hint (ex-data e)) (ex-message e))]
(if (json-request? request)
{::yres/status 200
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (t/encode-str {:query query :error error}
{:type :json-verbose})}
(graph-console-response (console-context profile-id
:query query
:error error))))))))
(defn import-handler
[{:keys [::db/pool] :as cfg} {:keys [params ::session/profile-id] :as request}]
(when-not (contains? params :file)
@@ -518,6 +742,27 @@
;; INIT
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def normalize-identity
"Let a personal access token stand in for a browser session on `/dbg`.
The identity may arrive either way, and both are the same profile acting for
itself. The gate below, devenv or an address in `:admins`, does not change
with the carrier. What a token adds is scripted access, so a tool already
holding a profile's credentials can also pull the graph a build produced.
Normalizing here rather than in each handler keeps `::session/profile-id`
the one key every debug handler reads, so a console session keyed by profile
(`app.graph.debug`) is found whichever way the caller authenticated."
{:name ::normalize-identity
:compile
(fn [& _]
(fn [handler]
(fn [request]
(handler (cond-> request
(and (nil? (::session/profile-id request))
(some? (::actoken/profile-id request)))
(assoc ::session/profile-id (::actoken/profile-id request)))))))})
(defn authorized?
[pool {:keys [::session/profile-id]}]
(or (and (= "devenv" (cf/get :host)) profile-id)
@@ -539,7 +784,12 @@
(letfn [(handle-error [cause]
(when-let [data (ex-data cause)]
(when (= :validation (:type data))
(str "Error: " (or (:hint data) (ex-message cause)) "\n"))))]
(let [hint (or (:hint data) (ex-message cause))
explain (ex/explain data)]
(str "Error: " hint
(when (and explain (not (str/includes? hint explain)))
(str "\n" explain))
"\n")))))]
{:name ::errors
:compile
(fn [& _params]
@@ -559,24 +809,51 @@
(assert (db/pool? (::db/pool params)) "expected a valid database pool")
(assert (session/manager? (::session/manager params)) "expected a valid session manager"))
(defn- graph-action-routes
[cfg]
[["/graph-export" {:handler (partial graph-export-handler cfg)}]
["/graph-load" {:handler (partial graph-load-handler cfg)}]
["/graph-query" {:handler (partial graph-query-handler cfg)}]
["/graph-unload" {:handler (partial graph-unload-handler cfg)}]
["/graph-reload" {:handler (partial graph-reload-handler cfg)}]
["/graph-sync-status" {:handler (partial graph-sync-status-handler cfg)}]
["/graph-data" {:handler (partial graph-data-handler cfg)}]
["/graph-files" {:handler (partial graph-files-handler cfg)}]])
(defmethod ig/init-key ::routes
[_ {:keys [::db/pool] :as cfg}]
[["/readyz" {:handler (partial health-handler cfg)}]
["/dbg" {:middleware [[session/authz cfg]
[with-authorization pool]]}
["" {:handler (partial index-handler cfg)}]
["/health" {:handler (partial health-handler cfg)}]
["/changelog" {:handler (partial changelog-handler cfg)}]
["/error/:id" {:handler (partial error-handler cfg)}]
["/error" {:handler (partial error-list-handler cfg)}]
["/actions" {:middleware [[errors]]}
["/set-virtual-clock"
{:handler (partial set-virtual-clock cfg)}]
["/resend-email-verification"
{:handler (partial resend-email-notification cfg)}]
["/handle-team-features"
{:handler (partial handle-team-features cfg)}]
["/file-export" {:handler (partial export-handler cfg)}]
["/file-import" {:handler (partial import-handler cfg)}]
["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]]]])
;; The graph routes are registered only under the `:graph` flag. Left
;; unregistered they 404, and nothing ever resolves `app.graph.*`. The `/dbg`
;; admin gate is unchanged: it covers the graph routes exactly as before.
(let [graph? (contains? cf/flags :graph)
actions (cond-> ["/actions" {:middleware [[errors]]}
["/set-virtual-clock"
{:handler (partial set-virtual-clock cfg)}]
["/resend-email-verification"
{:handler (partial resend-email-notification cfg)}]
["/handle-team-features"
{:handler (partial handle-team-features cfg)}]
["/file-export" {:handler (partial export-handler cfg)}]
["/file-import" {:handler (partial import-handler cfg)}]
["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]]
graph? (into (graph-action-routes cfg)))
dbg (cond-> ["/dbg" {:middleware [[session/authz cfg]
[actoken/authz cfg]
[normalize-identity]
[with-authorization pool]]}
["" {:handler (partial index-handler cfg)}]
["/health" {:handler (partial health-handler cfg)}]
["/changelog" {:handler (partial changelog-handler cfg)}]
["/error/:id" {:handler (partial error-handler cfg)}]
["/error" {:handler (partial error-list-handler cfg)}]
actions]
graph? (conj ["/graph" {:handler (partial graph-console-handler cfg)}]))]
(when graph?
;; With the flag on, the Ladybug native library belongs to this process,
;; so load it here. A missing or unusable library then fails the boot
;; instead of the first console request.
(require 'app.graph.debug 'app.graph.ingest))
[["/readyz" {:handler (partial health-handler cfg)}]
dbg]))
+1
View File
@@ -284,6 +284,7 @@
::http.debug/routes
{::db/pool (ig/ref ::db/pool)
::session/manager (ig/ref ::session/manager)
::mbus/msgbus (ig/ref ::mbus/msgbus)
::sto/storage (ig/ref ::sto/storage)
::setup/props (ig/ref ::setup/props)}
+46
View File
@@ -398,6 +398,52 @@
(println (sm/humanize-explain explain))
(ex/print-throwable cause))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GRAPH / LADYBUG
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; The graph namespaces resolve at call time, never at the top of this
;; namespace. `app.graph.ladybug` imports `com.ladybugdb.*`, and this namespace
;; loads with the REPL server on every boot, so a top-level require would link
;; the Ladybug native library into every backend, graph or not. Calling one of
;; the functions below loads the library at that point: the operator has asked
;; for it explicitly. The `:graph` flag gates the request path
;; (`app.http.debug`), not the REPL.
(defn graph-smoke-test!
"Execute a basic Ladybug smoke test (CREATE + count).
Uses the embedded Ladybug Java API. Use :db-path \":memory:\" (default)
or a filesystem path such as /tmp/test.lbug."
[& {:keys [db-path] :or {db-path ":memory:"}}]
((requiring-resolve 'app.graph.ladybug/smoke-test!) :db-path db-path))
(defn graph-query-test!
"Query Document count for a file's graph db (REPL diagnostic)."
[file-id & {:keys [db-path]}]
(let [file-id (h/parse-uuid file-id)
db-path (or db-path ((requiring-resolve 'app.graph.ladybug/db-path-for-file) file-id))
query-scalar! (requiring-resolve 'app.graph.ladybug/query-scalar!)
stmt "MATCH (n:Document) RETURN count(n) AS Document_c;"]
(query-scalar! db-path stmt)))
(defn ingest-file-to-graph!
"Project a Penpot file into a per-file Ladybug database.
Loads and realizes the file from the database, ensures the slice schema,
projects Document/Page/shape nodes, and returns graph stats.
Options:
- `:db-path` path or `:memory:`
- `:reset-db?` delete any existing db first (default true)
- `:skip-stats?` skip post-ingest MATCH count queries (default false)"
[file-id & opts]
(let [ingest-file! (requiring-resolve 'app.graph.ingest/ingest-file!)
print-ingest! (requiring-resolve 'app.graph.report/print-ingest!)
result (apply ingest-file! sys/system file-id opts)]
(print-ingest! result)
result))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PROCESSING
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -0,0 +1,165 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.graph-binder-gate-test
"Binder gate for the incremental-sync statement templates.
Every template `app.graph.sync` emits is *prepared* — parsed and bound by
the engine against the live DDL — and never executed. A parse or bind
failure (a renamed column, a reserved-word label emitted unquoted, a dropped
table) turns the gate red here, before the statement can reach a live
session.
One instance per template is the gate; per-column type coverage belongs to
beadpot's schema diff, not here. The templates are `defn-`, so they are
reached through their vars."
(:require
[app.graph.ladybug :as ladybug]
[app.graph.schema.nodes :as nodes]
[app.graph.sync]
[clojure.test :as t]))
(def ^:private create-node-statement #'app.graph.sync/create-node-statement)
(def ^:private delete-node-statement #'app.graph.sync/delete-node-statement)
(def ^:private create-edge-statement #'app.graph.sync/create-edge-statement)
(def ^:private delete-edge-statement #'app.graph.sync/delete-edge-statement)
(def ^:private set-edge-position-statement #'app.graph.sync/set-edge-position-statement)
(def ^:private create-instance-of-statement #'app.graph.sync/create-instance-of-statement)
(def ^:private delete-instance-of-statement #'app.graph.sync/delete-instance-of-statement)
(def ^:private set-node-attr-statement #'app.graph.sync/set-node-attr-statement)
(def ^:private set-page-name-statement #'app.graph.sync/set-page-name-statement)
(def ^:private remove-node-attr-statement #'app.graph.sync/remove-node-attr-statement)
(def ^:private set-document-revision-statement #'app.graph.sync/set-document-revision-statement)
;; Dummy identities. Fixed rather than generated: a gate failure should read
;; the same on every run.
(def ^:private doc-id #uuid "00000000-0000-0000-0000-0000000000d0")
(def ^:private page-id #uuid "00000000-0000-0000-0000-0000000000a0")
(def ^:private shape-id #uuid "00000000-0000-0000-0000-0000000000b0")
(def ^:private frame-id #uuid "00000000-0000-0000-0000-0000000000c0")
(def ^:private component-id #uuid "00000000-0000-0000-0000-0000000000e0")
(def ^:private child-edge
{:from-table "Rectangle" :from-id shape-id
:to-table "Page" :to-id page-id
:position 3})
(def ^:private ^:dynamic *conn* nil)
(defn- with-graph-connection
"Open a `:memory:` database, create the live schema, run the tests on it.
Nothing is executed against it — the gate only prepares — but the DDL has to
be there for the binder to resolve tables and columns against."
[next]
(ladybug/with-connection! ":memory:"
(fn [conn]
(ladybug/exec-on-connection! conn (nodes/ddl-statements))
(binding [*conn* conn]
(next)))))
(t/use-fixtures :once with-graph-connection)
(defn- gate
"Assert `statement` binds, and that the engine agrees on read/write."
[label statement read-only?]
(let [result (ladybug/validate-on-connection! *conn* statement)]
(t/is (:ok? result)
(str label " does not bind: " (:error result) "\n " statement))
(when (:ok? result)
(t/is (= read-only? (:read-only? result))
(str label " read-only? " (:read-only? result) ", expected " read-only?)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; the eleven sync templates
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest create-node-binds
(gate "create-node-statement"
(create-node-statement "Rectangle" {:id shape-id
:name "a shape"
:opacity 1.0
:hidden false})
false))
(t/deftest delete-node-binds
(gate "delete-node-statement"
(delete-node-statement "Rectangle" shape-id)
false))
(t/deftest create-edge-binds
(gate "create-edge-statement"
(create-edge-statement child-edge)
false))
(t/deftest delete-edge-binds
(gate "delete-edge-statement"
(delete-edge-statement (dissoc child-edge :position))
false))
(t/deftest set-edge-position-binds
(gate "set-edge-position-statement"
(set-edge-position-statement child-edge)
false))
(t/deftest create-instance-of-binds
(gate "create-instance-of-statement"
(create-instance-of-statement frame-id component-id)
false))
(t/deftest delete-instance-of-binds
(gate "delete-instance-of-statement"
(delete-instance-of-statement frame-id)
false))
(t/deftest set-node-attr-binds
(gate "set-node-attr-statement"
(set-node-attr-statement "Rectangle" shape-id :name "a shape")
false))
(t/deftest set-page-name-binds
(gate "set-page-name-statement"
(set-page-name-statement page-id "a page")
false))
(t/deftest remove-node-attr-binds
(gate "remove-node-attr-statement"
(remove-node-attr-statement "Rectangle" shape-id :name)
false))
(t/deftest set-document-revision-binds
(gate "set-document-revision-statement"
(set-document-revision-statement doc-id 42)
false))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; label quoting across the registry
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest every-node-label-binds
;; `Group` and `Boolean` are reserved words: unquoted they do not parse.
;; One MATCH per registered table is the cheapest way to keep `match-label`
;; honest as tables come and go.
(doseq [table (map :table nodes/node-types)]
(gate (str "delete-node-statement on " table)
(delete-node-statement table shape-id)
false)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; the gate itself
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest read-only-discriminates
;; Without this the `read-only? false` assertions above would hold for a
;; `validate-on-connection!` that always answered false.
(gate "a read query" "MATCH (n:Rectangle) RETURN count(n);" true))
(t/deftest bad-statement-is-reported-not-thrown
(let [result (ladybug/validate-on-connection!
*conn* "MATCH (n:Rectangle) SET n.no_such_column = 1;")]
(t/is (false? (:ok? result)))
(t/is (string? (:error result)))
(t/is (nil? (:read-only? result)))))
@@ -0,0 +1,280 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.graph-sync-parity-test
"Cold projection and incremental sync are two implementations of one mapping,
and this namespace holds them to it.
`app.graph.projection.document/projection-data` reads a whole file and produces
the whole graph. `app.graph.sync/apply-changes!` takes the change vocabulary
the editor emits and mutates an already open graph. A graph the second one
maintained must equal a graph the first one would build from the same file,
or the console shows a graph no rebuild reproduces.
The round trip: project a file cold into A, apply a change list to A and the
same list to the file data, project the resulting data cold into B, and diff
A against B. Two `:memory:` databases, no Postgres, no session."
(:require
[app.common.features :as ffeat]
[app.common.files.changes :as cfc]
[app.common.time :as ct]
[app.common.types.file :as ctf]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[app.graph.arrow :as arrow]
[app.graph.ladybug :as ladybug]
[app.graph.projection.document :as projection.document]
[app.graph.projection.transforms :as projection.transforms]
[app.graph.schema.nodes :as nodes]
[app.graph.sync :as sync]
[clojure.test :as t]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; the fixture file
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Fixed ids: a failure should read the same on every run.
(def ^:private file-id #uuid "00000000-0000-0000-0000-00000000f11e")
(def ^:private page-id #uuid "00000000-0000-0000-0000-0000000000a1")
(def ^:private page2-id #uuid "00000000-0000-0000-0000-0000000000a2")
(def ^:private frame-id #uuid "00000000-0000-0000-0000-0000000000f1")
(def ^:private rect-id #uuid "00000000-0000-0000-0000-0000000000b1")
(def ^:private circ-id #uuid "00000000-0000-0000-0000-0000000000b2")
(def ^:private text-id #uuid "00000000-0000-0000-0000-0000000000b3")
(def ^:private rect2-id #uuid "00000000-0000-0000-0000-0000000000b4")
(def ^:private base-revn 1)
(defn- file-row
"The `file` map the projection reads, as `bfc/get-file` returns it minus the
data blob."
[revn]
{:id file-id
:name "graph sync parity fixture"
:revn revn
:version 70
:features #{"components/v2"}
:created-at (ct/inst "2026-01-01T00:00:00Z")
:modified-at (ct/inst "2026-01-02T00:00:00Z")})
(defn- base-data
[]
(binding [ffeat/*current* #{"components/v2"}]
(ctf/make-file-data file-id page-id)))
(defn- shape
[id type attrs]
(cts/setup-shape (merge {:id id
:type type
:frame-id uuid/zero
:parent-id uuid/zero}
attrs)))
(def ^:private changes
"One change of every kind the sync path claims to support that this fixture
can exercise, in the order an editing session would emit them.
Four siblings in one container, then a reorder, a reparent, and a delete:
sibling order is where the two paths are easiest to get wrong, because the
stored `:shapes` list and `IsChildOf.position` run opposite ways."
[{:type :add-obj :page-id page-id :id frame-id
:parent-id uuid/zero :frame-id uuid/zero
:obj (shape frame-id :frame {:name "Board" :width 400 :height 300})}
{:type :add-obj :page-id page-id :id rect-id
:parent-id frame-id :frame-id frame-id
:obj (shape rect-id :rect {:name "Rect" :parent-id frame-id :frame-id frame-id
:width 100 :height 50})}
{:type :add-obj :page-id page-id :id circ-id
:parent-id frame-id :frame-id frame-id
:obj (shape circ-id :circle {:name "Circle" :parent-id frame-id :frame-id frame-id
:width 40 :height 40})}
{:type :add-obj :page-id page-id :id text-id
:parent-id frame-id :frame-id frame-id
:obj (shape text-id :text {:name "Label" :parent-id frame-id :frame-id frame-id})}
{:type :add-obj :page-id page-id :id rect2-id
:parent-id frame-id :frame-id frame-id
:obj (shape rect2-id :rect {:name "Rect two" :parent-id frame-id :frame-id frame-id
:width 20 :height 20})}
;; A rename, and two attributes whose values are falsy: `blocked false` and
;; `opacity 0` are values, not absences, on both paths.
{:type :mod-obj :page-id page-id :id rect-id
:operations [{:type :set :attr :name :val "Renamed rect"}
{:type :set :attr :blocked :val false}
{:type :set :attr :opacity :val 0}]}
;; Reorder inside the same container: the edge keeps its endpoints and
;; every sibling it passes has to move.
{:type :mov-objects :page-id page-id :parent-id frame-id :index 0 :shapes [circ-id]}
;; Reparent to the page's root frame: the edge moves, and so do the
;; shape's own `parent_id` and `frame_id`.
{:type :mov-objects :page-id page-id :parent-id uuid/zero :index 0 :shapes [text-id]}
;; Delete with survivors: the gap in the sibling numbering has to close.
{:type :del-obj :page-id page-id :id rect-id}
{:type :add-page :id page2-id :name "Page two"}
{:type :mod-page :id page-id :name "Page one, renamed"}])
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; projecting and reading back
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- load-graph!
"Create the schema on `conn`, project `data` into it, run the transforms.
Returns the projection, which is also what the sync index is built from."
[conn data file]
(let [projection (projection.document/projection-data data file)]
(ladybug/exec-on-connection! conn (nodes/ddl-statements))
(arrow/with-allocator!
(fn [allocator] (arrow/load-projection! conn projection allocator)))
(projection.transforms/apply-transforms! nil conn data file)
projection))
(defn- rel-tables
[conn]
(mapv first (:rows (ladybug/query-on-connection!
conn "CALL show_tables() WHERE type = 'REL' RETURN name;"
:max-rows 1000))))
(defn- rel-properties
"Property names on rel table `rel`, in catalog order."
[conn rel]
(mapv (comp str second)
(:rows (ladybug/query-on-connection!
conn (str "CALL table_info('" rel "') RETURN *;")
:max-rows 1000))))
(defn- node-rows
[conn table]
(:rows (ladybug/query-on-connection!
conn (str "MATCH (n:" (nodes/match-label table) ") RETURN n.* ORDER BY n.id;")
:max-rows 100000)))
(defn- edge-rows
[conn rel props]
(let [returns (into ["a.id" "b.id"] (map #(str "r.`" % "`")) props)]
(:rows (ladybug/query-on-connection!
conn (str "MATCH (a)-[r:`" rel "`]->(b) "
"RETURN " (clojure.string/join ", " returns) " "
"ORDER BY a.id, b.id;")
:max-rows 100000))))
(defn- keyed-rows
"Rows as `{key {column value}}`, so a difference names a row and a column.
Values are stringified: both connections hand a value back through the same
reader, so any difference in the strings is a difference in the graph."
[columns key-columns rows]
(into {}
(map (fn [row]
(let [cells (zipmap columns (map str row))]
[(mapv cells key-columns) cells])))
rows))
(defn- snapshot
"Every node row and every edge row in the database, keyed by table."
[conn]
{:nodes (into {}
(map (fn [{:keys [table]}]
(let [columns (nodes/columns table)]
[table (keyed-rows columns ["id"] (node-rows conn table))])))
nodes/node-types)
:edges (into {}
(map (fn [rel]
(let [columns (into ["from" "to"] (rel-properties conn rel))]
[rel (keyed-rows columns ["from" "to"]
(edge-rows conn rel (rel-properties conn rel)))])))
(rel-tables conn))})
(defn- row-diff
[rows-a rows-b]
(into {}
(for [k (sort (into #{} (concat (keys rows-a) (keys rows-b))))
:let [a (get rows-a k)
b (get rows-b k)]
:when (not= a b)]
[k (cond
(nil? a) {:only-in :rebuilt}
(nil? b) {:only-in :synced}
:else (into {}
(for [c (sort (into #{} (concat (keys a) (keys b))))
:when (not= (get a c) (get b c))]
[c {:synced (get a c) :rebuilt (get b c)}])))])))
(defn- diff
"Where the two snapshots disagree, down to the row and the column."
[a b]
(into {}
(for [kind [:nodes :edges]
table (sort (into #{} (concat (keys (get a kind)) (keys (get b kind)))))
:let [d (row-diff (get-in a [kind table]) (get-in b [kind table]))]
:when (seq d)]
[[kind table] d])))
(defn- with-two-connections
[f]
(ladybug/with-connection! ":memory:"
(fn [conn-a]
(ladybug/with-connection! ":memory:"
(fn [conn-b]
(f conn-a conn-b))))))
(defn- round-trip
"Sync `change-list` into A, rebuild the same file into B, return the diff."
[change-list]
(let [data0 (base-data)
data1 (cfc/process-changes data0 change-list)
revn1 (inc base-revn)]
(with-two-connections
(fn [conn-a conn-b]
(let [projection (load-graph! conn-a data0 (file-row base-revn))
index (sync/build-index file-id base-revn projection)
result (sync/apply-changes! conn-a index change-list revn1)]
(load-graph! conn-b data1 (file-row revn1))
{:diff (diff (snapshot conn-a) (snapshot conn-b))
:applied (:applied result)
:skipped (:skipped result)})))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; the tests
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest every-change-in-the-list-is-supported
(let [{:keys [applied skipped]} (round-trip changes)]
(t/is (empty? skipped)
(str "the fixture must exercise the sync path, not the skip path: " (pr-str skipped)))
(t/is (= (count changes) (count applied)))))
(t/deftest synced-graph-equals-rebuilt-graph
(let [{:keys [diff]} (round-trip changes)]
(t/is (empty? diff)
(str "cold projection and sync replay disagree on "
(pr-str (keys diff)) "\n" (pr-str diff)))))
(t/deftest the-diff-catches-an-injected-sync-bug
;; The round trip is only worth running if it fails when sync is wrong.
;; `apply-mov-objects` maintains `IsChildOf`; drop the change from the list
;; sync sees, keep it in the list the file sees, and the edge must differ.
(let [data0 (base-data)
data1 (cfc/process-changes data0 changes)
crippled (remove #(= :mov-objects (:type %)) changes)
revn1 (inc base-revn)
result (with-two-connections
(fn [conn-a conn-b]
(let [projection (load-graph! conn-a data0 (file-row base-revn))
index (sync/build-index file-id base-revn projection)]
(sync/apply-changes! conn-a index crippled revn1)
(load-graph! conn-b data1 (file-row revn1))
(diff (snapshot conn-a) (snapshot conn-b)))))]
(t/is (contains? result [:edges "IsChildOf"])
"a sync that skips a reparent must show up as an IsChildOf difference")))
+1 -1
View File
@@ -55,7 +55,7 @@
io.aviso/pretty {:mvn/version "1.4.4"}
environ/environ {:mvn/version "1.2.0"}}
:paths ["src" "vendor" "target/classes"]
:paths ["src" "vendor" "resources" "target/classes"]
:aliases
{:dev
{:extra-deps
+4 -1
View File
@@ -57,6 +57,7 @@
"text-editor/v2"
"text-editor-wasm/v1"
"render-wasm/v1"
"wasm-export/v1"
"variants/v1"})
;; A set of features enabled by default
@@ -82,7 +83,8 @@
"text-editor/v2"
"text-editor-wasm/v1"
"tokens/numeric-input"
"render-wasm/v1"})
"render-wasm/v1"
"wasm-export/v1"})
;; Features that are mainly backend only or there are a proper
;; fallback when frontend reports no support for it
@@ -132,6 +134,7 @@
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
:feature-text-editor-wasm "text-editor-wasm/v1"
:feature-render-wasm "render-wasm/v1"
:feature-wasm-export "wasm-export/v1"
:feature-variants "variants/v1"
:feature-token-input "tokens/numeric-input"
nil))
+7
View File
@@ -100,6 +100,10 @@
:backend-svgo
;; If enabled, it makes the Google Fonts available.
:google-fonts-provider
;; Enables the Ladybug graph subsystem: the `/dbg` graph console and its
;; actions. Off by default. With the flag off, `app.graph.*` never loads,
;; so the Ladybug native library never enters the JVM.
:graph
;; Only for development.
:nrepl-server
;; Interactive repl. Only for development.
@@ -177,6 +181,9 @@
:stroke-path
:stroke-per-side
;; Exporter only: uses render-wasm for export instead of browser
;; renderer.
:wasm-export
:custom-shortcuts
:remote-media-processing})
@@ -4,8 +4,9 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.main.fonts
(ns app.common.fonts
"A fonts loading macros."
(:require
[app.common.uuid :as uuid]
[clojure.data.json :as json]
@@ -47,6 +48,3 @@
(let [data (slurp (io/resource path))
data (json/read-str data)]
`~(mapv parse-gfont (get data "items"))))
@@ -4,13 +4,159 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.fallback-fonts
"Host-agnostic fallback-font knowledge: which scripts/emoji a text uses and
which (google) fallback fonts cover them. Pure data + pure fns — no browser
or Node dependencies — so the workspace (`api.texts`/`api.fonts`) and the
headless exporter (`app.renderer.wasm`) compute the SAME fallback set from
the same source. Anything a host must fetch/upload for text to render
belongs here, not in host code.")
(ns app.common.fonts
"Host-agnostic font knowledge shared by every renderer: the google catalog
baked at compile time from `common/resources/fonts/gfonts.*.json`, the
font-id/uuid mapping, weight/style variant resolution, and the noto fallback
fonts a text's scripts and emoji need. Also the one family bundled with the
frontend, which is not a google font but resolves by the same rules.
Pure data + pure fns — no browser or Node dependencies — so the workspace and
the headless exporter resolve the SAME fonts from the same source. Anything a
host must fetch or upload for text to render belongs here, not in host code."
(:require-macros [app.common.fonts :refer [preload-gfonts]])
(:require
[app.common.data :as d]
[app.common.uuid :as uuid]
[cuerdas.core :as str]))
;; --- GOOGLE FONTS CATALOG
(def catalog
(preload-gfonts "fonts/gfonts.2025.11.28.json"))
(def ^:private by-id
(reduce (fn [m font] (assoc m (:id font) font)) {} catalog))
(def ^:private by-uuid
(reduce (fn [m font] (assoc m (:uuid font) font)) {} catalog))
(defn gfont-id->uuid
"Maps a `gfont-<slug>` id to its (compilation-stable) catalog uuid, or nil."
[gfont-id]
(:uuid (get by-id gfont-id)))
;; --- font-id -> wasm uuid
(def ^:private custom-prefix "custom-")
(def ^:private gfont-prefix "gfont-")
(defn font-id->backend
"Which source a content font-id comes from: `:google` for `gfont-<slug>`,
`:custom` for `custom-<uuid>`, `:builtin` for everything else (bundled
families, but also unknown or malformed ids — the same bucket
`font-id->uuid` maps to `uuid/zero`)."
[font-id]
(cond
(not (string? font-id)) :builtin
(str/starts-with? font-id gfont-prefix) :google
(str/starts-with? font-id custom-prefix) :custom
:else :builtin))
(defn font-id->uuid
"Maps a content font-id to the uuid WASM keys fonts by:
- `gfont-<slug>` -> the catalog uuid,
- `custom-<uuid>` -> that uuid,
- anything else (builtin, unknown, malformed) -> `uuid/zero`, which WASM
resolves to the default font."
[font-id]
(case (font-id->backend font-id)
:google (or (gfont-id->uuid font-id) uuid/zero)
:custom (or (uuid/parse* (subs font-id (count custom-prefix))) uuid/zero)
uuid/zero))
;; --- proxy urls
(def ^:private gstatic-prefix
"https://fonts.gstatic.com/s")
(defn gstatic->proxy-url
[s base]
(let [base (str/rtrim (str base) "/")]
(str/replace (str s) gstatic-prefix base)))
;; --- variant resolution
(defn closest-variant
[variants target-weight target-style]
(when-let [target-weight (d/parse-integer target-weight)]
(let [result
(reduce
(fn [closest-match variant]
(let [weight (d/parse-integer (:weight variant))
distance (abs (- target-weight weight))
matches-style? (= target-style (:style variant))
current {:variant variant
:weight weight
:distance distance}]
(cond
;; Exact match found
(and (zero? distance)
(if target-style matches-style? true))
(reduced current)
(nil? closest-match) current
;; Update best match if this variant is closer or equal distance but higher weight
(or (< distance (:distance closest-match))
(and (= distance (:distance closest-match))
(> weight (:weight closest-match))))
current
;; Same weight as the `closest-match` but the style matches `target-style`
(and (= weight (:weight closest-match)) matches-style?)
current
:else
closest-match)))
nil
variants)]
(:variant result))))
(defn resolve-ttf-url
[font-uuid weight style]
(when-let [font (get by-uuid font-uuid)]
(let [style (if (zero? style) "normal" "italic")
variants (:variants font)]
(:ttf-url (or (closest-variant variants weight style)
(first variants))))))
;; --- BUILTIN FONTS
;;
;; Bundled with the frontend, served from `<public-uri>/fonts/`. Shared so the
;; workspace and the exporter upload the same TTF for a given weight/style.
(def local-fonts
[{:id "sourcesanspro"
:name "Source Sans Pro"
:family "sourcesanspro"
:variants
[{:id "200" :name "200" :weight "200" :style "normal" :suffix "extralight" :ttf-url "sourcesanspro-extralight.ttf"}
{:id "200italic" :name "200 Italic" :weight "200" :style "italic" :suffix "extralightitalic" :ttf-url "sourcesanspro-extralightitalic.ttf"}
{:id "300" :name "300" :weight "300" :style "normal" :suffix "light" :ttf-url "sourcesanspro-light.ttf"}
{:id "300italic" :name "300 Italic" :weight "300" :style "italic" :suffix "lightitalic" :ttf-url "sourcesanspro-lightitalic.ttf"}
{:id "regular" :name "400" :weight "400" :style "normal" :ttf-url "sourcesanspro-regular.ttf"}
{:id "italic" :name "400 Italic" :weight "400" :style "italic" :ttf-url "sourcesanspro-italic.ttf"}
{:id "600" :name "600" :weight "600" :style "normal" :suffix "semibold" :ttf-url "sourcesanspro-semibold.ttf"}
{:id "600italic" :name "600 Italic" :weight "600" :style "italic" :suffix "semibolditalic" :ttf-url "sourcesanspro-semibolditalic.ttf"}
{:id "bold" :name "700" :weight "700" :style "normal" :ttf-url "sourcesanspro-bold.ttf"}
{:id "bolditalic" :name "700 Italic" :weight "700" :style "italic" :ttf-url "sourcesanspro-bolditalic.ttf"}
{:id "black" :name "900" :weight "900" :style "normal" :ttf-url "sourcesanspro-black.ttf"}
{:id "blackitalic" :name "900 Italic" :weight "900" :style "italic" :ttf-url "sourcesanspro-blackitalic.ttf"}]}])
(defn resolve-ttf-file
"Builtin TTF file name for `weight` and `style` (0 normal, 1 italic), by the
same nearest-weight rule as the google catalog."
[weight style]
(let [variants (:variants (first local-fonts))]
(:ttf-url (or (closest-variant variants weight (if (zero? style) "normal" "italic"))
(first variants)))))
;; --- FALLBACK FONTS
;;
;; Which scripts/emoji a text uses and which (google) fallback fonts cover them.
(def ^:private emoji-pattern
#"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDFFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]")
@@ -0,0 +1,24 @@
# `app.common.render-wasm.*`
The host-agnostic ClojureScript side of the render-wasm binary protocol: byte
layouts, memory helpers and serializers that turn Penpot shapes into the buffers
`render-wasm` consumes.
The workspace drives it from `app.render-wasm.*`, the headless exporter from
`app.wasm.*` — same code underneath, so the two cannot drift.
Font knowledge is *not* here even though both hosts need it for rendering: it is
not specific to the wasm backend, so the google fonts catalog (baked from
`common/resources/fonts/gfonts.*.json`), the bundled builtin family and the
emoji/script fallback tables live in `app.common.fonts`. Likewise the image-id
enumeration lives in `app.common.types.shape.images`.
`shared.js` is not here: it is a per-build artifact, so each host compiles
against the copy from its own render-wasm build and passes it to
`wasm/init-serializers!` (see `app.render-wasm.api.enums`, `app.wasm.enums`).
## Rules for anything added here
**Nothing here may depend on a browser (no DOM, no WebGL, no app state) or on
`frontend/src`.** Dependencies are `app.common.*` and this subtree only. It also
has to run under plain Node — a `js/document` here breaks the exporter.
@@ -4,7 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.api.props
(ns app.common.render-wasm.api.props
"Browser-free WASM shape property setters, shared by the workspace render
orchestrator (`app.render-wasm.api`) and the headless exporter
(`app.wasm.serialize`).
@@ -15,15 +15,15 @@
data sources (fonts, image bytes, SVG static markup) stay in `app.render-wasm.api`."
(:require
[app.common.math :as mth]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.mem.heap32 :as mem.h32]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.serializers.color :as sr-clr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.fills :as types.fills]
[app.common.types.fills.impl :as types.fills.impl]
[app.common.types.path :as path]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.mem.heap32 :as mem.h32]
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.wasm :as wasm]))
[app.common.types.path :as path]))
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
@@ -4,7 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.api.shapes
(ns app.common.render-wasm.api.shapes
"Batched shape property serialization for improved WASM performance.
This module provides a single WASM call to set all base shape properties,
@@ -13,11 +13,11 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.uuid :as uuid]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.serializers :as sr]
[app.render-wasm.wasm :as wasm]))
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.wasm :as wasm]
[app.common.uuid :as uuid]))
;; Binary layout constants matching Rust implementation:
;;
@@ -110,13 +110,9 @@
blend-mode (sr/translate-blend-mode (get shape :blend-mode))
constraint-h (let [c (get shape :constraints-h)]
(if (some? c)
(sr/translate-constraint-h c)
CONSTRAINT-NONE))
(sr/translate-constraint-h c))
constraint-v (let [c (get shape :constraints-v)]
(if (some? c)
(sr/translate-constraint-v c)
CONSTRAINT-NONE))
(sr/translate-constraint-v c))
opacity (d/nilv (get shape :opacity) 1.0)
rotation (d/nilv (get shape :rotation) 0.0)
@@ -0,0 +1,54 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.common.render-wasm.enums
"Serializer enum table from `shared.js`")
(def ^:private serializer-exports
[["raster-format" "RasterFormat"]
["blur-type" "RawBlurType"]
["blend-mode" "RawBlendMode"]
["bool-type" "RawBoolType"]
["font-style" "RawFontStyle"]
["flex-direction" "RawFlexDirection"]
["grid-direction" "RawGridDirection"]
["grow-type" "RawGrowType"]
["align-items" "RawAlignItems"]
["align-self" "RawAlignSelf"]
["align-content" "RawAlignContent"]
["justify-items" "RawJustifyItems"]
["justify-content" "RawJustifyContent"]
["justify-self" "RawJustifySelf"]
["wrap-type" "RawWrapType"]
["grid-track-type" "RawGridTrackType"]
["shadow-style" "RawShadowStyle"]
["guide-kind" "RawGuideKind"]
["stroke-style" "RawStrokeStyle"]
["stroke-cap" "RawStrokeCap"]
["shape-type" "RawShapeType"]
["constraint-h" "RawConstraintH"]
["constraint-v" "RawConstraintV"]
["sizing" "RawSizing"]
["vertical-align" "RawVerticalAlign"]
["fill-data" "RawFillData"]
["text-align" "RawTextAlign"]
["text-direction" "RawTextDirection"]
["text-decoration" "RawTextDecoration"]
["text-transform" "RawTextTransform"]
["multiple-state" "MultipleState"]
["transform-entry-kind" "RawTransformEntryKind"]
["segment-data" "RawSegmentData"]
["stroke-linecap" "RawStrokeLineCap"]
["stroke-linejoin" "RawStrokeLineJoin"]
["fill-rule" "RawFillRule"]])
(defmacro serializers
[alias]
(let [alias (name alias)]
`(cljs.core/js-obj
~@(mapcat (fn [[key export]]
[key (symbol alias export)])
serializer-exports))))
@@ -4,8 +4,8 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.helpers
#?(:cljs (:require-macros [app.render-wasm.helpers]))
(ns app.common.render-wasm.helpers
#?(:cljs (:require-macros [app.common.render-wasm.helpers]))
(:require [app.common.data :as d]))
(def error-code
@@ -4,11 +4,11 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.mem
(ns app.common.render-wasm.mem
(:require
[app.common.buffer :as buf]
[app.render-wasm.helpers :as h]
[app.render-wasm.wasm :as wasm]))
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.wasm :as wasm]))
(defn ->offset-32
"Convert a 8-bit (1 byte) offset to a 32-bit (4 bytes) offset"
@@ -4,7 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.mem.heap32
(ns app.common.render-wasm.mem.heap32
"A memory write helpers that uses 32 bits addressed offsets."
(:require
[app.common.data.macros :as dm]
@@ -4,7 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.serialize-shape
(ns app.common.render-wasm.serialize-shape
"Single source of truth for the host-independent part of serializing a whole
shape into the WASM design state.
@@ -24,8 +24,8 @@
The incremental workspace edit path (`set-wasm-attr!`) is unaffected; it keeps
dispatching per changed key through the same underlying `props` setters."
(:require
[app.render-wasm.api.props :as props]
[app.render-wasm.api.shapes :as shapes]))
[app.common.render-wasm.api.props :as props]
[app.common.render-wasm.api.shapes :as shapes]))
(defn serialize-shape!
"Applies every host-independent WASM property of `shape`. `set-shape-base-props`
@@ -4,16 +4,16 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.serializers
(ns app.common.render-wasm.serializers
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.render-wasm.serializers.color :as sr-clr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.color :as clr]
[app.common.types.shape-tree :as ctst]
[app.common.uuid :as uuid]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.wasm :as wasm]
[cuerdas.core :as str]))
(defn u8
@@ -116,13 +116,13 @@
(defn translate-constraint-h
[type]
(let [values (unchecked-get wasm/serializers "constraint-h")
default 5] ;; TODO: fix code in rust so we have a proper None variant
default (unchecked-get values "none")]
(d/nilv (unchecked-get values (d/name type)) default)))
(defn translate-constraint-v
[type]
(let [values (unchecked-get wasm/serializers "constraint-v")
default 5] ;; TODO: fix code in rust so we have a proper None variant
default (unchecked-get values "none")]
(d/nilv (unchecked-get values (d/name type)) default)))
(defn translate-bool-type
@@ -1,4 +1,4 @@
(ns app.render-wasm.serializers.color
(ns app.common.render-wasm.serializers.color
(:require
[app.common.math :as mth]))
@@ -4,23 +4,24 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.text-content
(ns app.common.render-wasm.text-content
"Single source of truth for writing a text shape's content into the WASM design
state. The binary layout ([num-spans][paragraph attrs][span attrs][text]) is
identical for the workspace and the headless exporter — only *font resolution*
differs (the workspace uses the loaded fonts DB; the exporter uses its gfonts
catalog + custom variants). So the byte-writing lives here and font resolution
is injected via the `opts` map passed to `write-shape-text!`.
identical for the workspace and the headless exporter, and so is the font-id
-> uuid mapping (`cfnt/font-id->uuid`). Only *variant* resolution differs —
the workspace has a loaded fonts DB, the exporter does not — so that part is
injected via the `opts` map passed to `write-shape-text!`.
Fully portable (no store/DOM/React), so it runs under Node too."
(:require
[app.common.data :as d]
[app.common.fonts :as cfnt]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.fills.impl :as types.fills.impl]
[app.common.uuid :as uuid]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.serializers :as sr]
[app.render-wasm.wasm :as wasm]
[cuerdas.core :as str]))
(def ^:const PARAGRAPH-ATTR-U8-SIZE 12)
@@ -169,13 +170,15 @@
"Writes one paragraph's spans + text into WASM and appends it to the current
shape via `_set_shape_text_content`.
`opts` injects host-specific font resolution:
- `:normalize-font-id` (string font-id -> wasm uuid) — required in practice,
`opts` injects host-specific font handling:
- `:normalize-font-id` (string font-id -> wasm uuid) defaults to the shared
`cfnt/font-id->uuid`, which is what both hosts want — a host only
overrides it if it keys its font store some other way,
- `:normalize-paragraph`/`:normalize-span` — font-variant normalization from a
fonts DB (workspace); default to identity (the exporter resolves variants
differently / not at all)."
[spans paragraph text {:keys [normalize-font-id normalize-paragraph normalize-span]
:or {normalize-font-id identity
:or {normalize-font-id cfnt/font-id->uuid
normalize-paragraph identity
normalize-span (fn [span _paragraph] span)}}]
(let [paragraph (normalize-paragraph paragraph)
@@ -4,8 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.wasm
(:require ["./api/shared.js" :as shared]))
(ns app.common.render-wasm.wasm)
(defonce internal-frame-id nil)
(defonce internal-frame-type 0)
@@ -65,41 +64,19 @@
(set! gl-context nil)
(set! context-initialized? false))
(defonce serializers
#js {:raster-format shared/RasterFormat
:blur-type shared/RawBlurType
:blend-mode shared/RawBlendMode
:bool-type shared/RawBoolType
:font-style shared/RawFontStyle
:flex-direction shared/RawFlexDirection
:grid-direction shared/RawGridDirection
:grow-type shared/RawGrowType
:align-items shared/RawAlignItems
:align-self shared/RawAlignSelf
:align-content shared/RawAlignContent
:justify-items shared/RawJustifyItems
:justify-content shared/RawJustifyContent
:justify-self shared/RawJustifySelf
:wrap-type shared/RawWrapType
:grid-track-type shared/RawGridTrackType
:shadow-style shared/RawShadowStyle
:guide-kind shared/RawGuideKind
:stroke-style shared/RawStrokeStyle
:stroke-cap shared/RawStrokeCap
:shape-type shared/RawShapeType
:constraint-h shared/RawConstraintH
:constraint-v shared/RawConstraintV
:sizing shared/RawSizing
:vertical-align shared/RawVerticalAlign
:fill-data shared/RawFillData
:text-align shared/RawTextAlign
:text-direction shared/RawTextDirection
:text-decoration shared/RawTextDecoration
:text-transform shared/RawTextTransform
:multiple-state shared/MultipleState
:transform-entry-kind shared/RawTransformEntryKind
:segment-data shared/RawSegmentData
:stroke-linecap shared/RawStrokeLineCap
:stroke-linejoin shared/RawStrokeLineJoin
:fill-rule shared/RawFillRule})
(defonce serializers nil)
(defn init-serializers!
"Binds the enum table produced by the `enums/serializers` macro."
[table]
(let [missing (array)]
(doseq [key (js/Object.keys table)]
(when (undefined? (unchecked-get table key))
(.push missing key)))
(when (pos? (alength missing))
(throw (ex-info "stale or incomplete render-wasm shared.js"
{:missing (vec missing)})))
(set! serializers table)))
+104 -14
View File
@@ -233,7 +233,50 @@
[:grow-type {:optional true}
[::sm/one-of grow-types]]
[:applied-tokens {:optional true} cto/schema:applied-tokens]
[:plugin-data {:optional true} ctpg/schema:plugin-data]])
[:plugin-data {:optional true} ctpg/schema:plugin-data]
;; `rotation`, `flip-x` and `flip-y` are fields of the `Shape` record (see
;; `cr/defrecord Shape` above) and this schema did not declare them.
;; `rotation` was already named in `allowed-shape-attrs` here and in
;; `app.common.types.shape.attrs/editable-attrs`, so the omission was in this
;; schema and not in the model. Anything reading the model from the schema
;; rather than from a live shape missed all three: the graph projection
;; derives one column per entry (`app.graph.schema.projection`), so shape
;; nodes carried no rotation at all, and a consumer cannot place a shape
;; without it.
;;
;; Nilable, because `app.common.record/defrecord` cannot remove a base
;; field: its `without` assocs nil and its `containsKey` answers true
;; whatever the field holds, so nil is how a record field says "unset".
;; `flip-x` and `flip-y` are nil on every shape `setup-shape` builds, since
;; `make-minimal-shape` gives them no default.
;;
;; Optional as well, unlike the geometry group below, because this schema
;; has a second job: `check-shape-generic-attrs` validates partial update
;; payloads with it, such as the `{:blocked true}` that
;; `app.main.data.workspace/update-shape` passes. A required key here would
;; reject every such payload.
[:rotation {:optional true} [:maybe ::sm/safe-number]]
[:flip-x {:optional true} [:maybe :boolean]]
[:flip-y {:optional true} [:maybe :boolean]]
;; Carried on circles, rects and texts too, not only on frames, so it
;; belongs here rather than in `schema:frame-attrs`. Not nilable: the key
;; lives outside the record, `app.common.logic.shapes` dissocs it to unset
;; it, and `setup-shape` drops it when a caller passes nil.
[:hide-in-viewer {:optional true} :boolean]
;; The SVG provenance an import leaves on a shape. Typed `:map` rather than
;; more precisely on purpose: legacy files hold `svg-transform` as a plain
;; `{:a … :f}` map rather than a `::gmt/matrix` record, and `svg-viewbox` as
;; either a `::grc/rect` record or a plain map, so a tighter schema here
;; would reject files that are otherwise valid. The graph *column* types are
;; tightened separately, where a wrong guess costs a column rather than a
;; rejected file (`app.graph.schema.contract/type-overrides`).
[:svg-attrs {:optional true} :map]
[:svg-defs {:optional true} :map]
[:svg-transform {:optional true} :map]
[:svg-viewbox {:optional true} :map]])
(def schema:group-attrs
[:map {:title "GroupAttrs"}
@@ -244,7 +287,30 @@
[:shapes [:vector {:gen/max 10 :gen/min 1} ::sm/uuid]]
[:hide-fill-on-export {:optional true} :boolean]
[:show-content {:optional true} :boolean]
[:hide-in-viewer {:optional true} :boolean]])
;; `hide-in-viewer` moved to `schema:shape-generic-attrs`: stored files carry
;; it on circles, rects and texts too, not only on frames.
;; `use-for-thumbnail` is a frame attribute the model has long had, since
;; `app.common.files.migrations` renames `:use-for-thumbnail?` to it and
;; `app.common.logic.libraries` reads it, and this schema had not declared.
[:use-for-thumbnail {:optional true} :boolean]])
(def ^:private schema:nilable-geom-attrs
"`schema:shape-geom-attrs`, but nilable.
Bools and paths are the only two shape types whose geometry can be nil:
`make-minimal-shape` gives `x`, `y`, `width` and `height` a default for every
other type and skips those two, whose extent their content and `selrect`
imply instead. The four keys stay required, because they are `Shape` record
fields and `app.common.record/defrecord` keeps a base field present whatever
it holds. So these two branches cannot merge `schema:shape-geom-attrs`, which
rejects the nil, and declare the same four keys nilable instead. A
schema-derived reader previously saw a bool or a path as having no position or
size at all."
[:map {:title "NilableGeometryAttrs"}
[:x [:maybe ::sm/safe-number]]
[:y [:maybe ::sm/safe-number]]
[:width [:maybe ::sm/safe-number]]
[:height [:maybe ::sm/safe-number]]])
(def ^:private schema:bool-attrs
[:map {:title "BoolAttrs"}
@@ -253,10 +319,19 @@
[:content path/schema:content]])
(def ^:private schema:rect-attrs
[:map {:title "RectAttrs"}])
[:map {:title "RectAttrs"}
;; Legacy radii, set by SVG import (`app.common.files.shapes-builder` parses
;; `rx`/`ry` off the element) and by migration 0003, which assocs `0`.
;; Superseded by `r1` to `r4`, but stored files still carry them. Not
;; nilable: both keys live outside the `Shape` record, so a dissoc removes
;; them, and `setup-shape` drops a nil before the merge.
[:rx {:optional true} ::sm/safe-number]
[:ry {:optional true} ::sm/safe-number]])
(def ^:private schema:circle-attrs
[:map {:title "CircleAttrs"}])
[:map {:title "CircleAttrs"}
[:rx {:optional true} ::sm/safe-number]
[:ry {:optional true} ::sm/safe-number]])
(def ^:private schema:svg-raw-attrs
[:map {:title "SvgRawAttrs"}
@@ -266,7 +341,15 @@
;; keeps the child ids typed as uuid, so a JSON round trip (binfile
;; export/import) decodes them back to uuids instead of leaving
;; strings that no longer resolve against the objects map.
[:shapes {:optional true} [:vector {:gen/max 10} ::sm/uuid]]])
[:shapes {:optional true} [:vector {:gen/max 10} ::sm/uuid]]
;; The raw SVG node an import kept.
;; `app.common.files.shapes-builder/create-raw-svg` sets it and
;; `allowed-svg-attrs` names it. Usually the parsed element,
;; `{:tag … :attrs … :content …}`, but a bare text node arrives as the
;; string itself: `<text>hi</text>` becomes one svg-raw for the element
;; and another for `"hi"`. `app.common.files.shapes-builder/parse-svg-element`
;; carries a FIXME about exactly that. Both forms are legal and stored.
[:content {:optional true} [:or :map :string]]])
(def schema:image-attrs
[:map {:title "ImageAttrs"}
@@ -301,7 +384,10 @@
(->> (sg/generator schema:shape-base-attrs)
(sg/mcat (fn [{:keys [type] :as shape}]
(sg/let [attrs1 (sg/generator schema:shape-generic-attrs)
attrs2 (sg/generator schema:shape-geom-attrs)
attrs2 (if (or (= type :path)
(= type :bool))
(sg/generator schema:nilable-geom-attrs)
(sg/generator schema:shape-geom-attrs))
attrs3 (case type
:text (sg/generator schema:text-attrs)
:path (sg/generator schema:path-attrs)
@@ -312,10 +398,7 @@
:bool (sg/generator schema:bool-attrs)
:group (sg/generator schema:group-attrs)
:frame (sg/generator schema:frame-attrs))]
(if (or (= type :path)
(= type :bool))
(merge attrs1 shape attrs3)
(merge attrs1 shape attrs2 attrs3)))))
(merge attrs1 shape attrs2 attrs3))))
(sg/fmap create-shape)))
(def schema:shape-attrs
@@ -347,6 +430,7 @@
ctsl/schema:layout-child-attrs
schema:bool-attrs
schema:shape-generic-attrs
schema:nilable-geom-attrs
schema:shape-base-attrs]]
[:rect
@@ -386,6 +470,7 @@
ctsl/schema:layout-child-attrs
schema:path-attrs
schema:shape-generic-attrs
schema:nilable-geom-attrs
schema:shape-base-attrs]]
[:text
@@ -572,10 +657,15 @@
[type]
(let [type (if (= type :curve) :path type)
attrs (get-minimal-shape type)
attrs (cond-> attrs
(and (not= :path type)
(not= :bool type))
(-> (assoc :x 0)
attrs (if (or (= :path type)
(= :bool type))
(-> attrs
(assoc :x nil)
(assoc :y nil)
(assoc :width nil)
(assoc :height nil))
(-> attrs
(assoc :x 0)
(assoc :y 0)
(assoc :width 0.01)
(assoc :height 0.01)))
@@ -4,12 +4,12 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.resources
(ns app.common.types.shape.images
"Host-agnostic enumeration of the external resources a scene needs to
render: which image bytes its shapes reference. Pure data walking — no
browser or Node dependencies — so the workspace and the headless exporter
derive the same set from the same source (sibling of
`app.render-wasm.fallback-fonts`, which does the same for fonts)."
derive the same set from the same source (counterpart of
`app.common.fonts`, which does the same for fonts)."
(:require
[app.common.types.fills :as types.fills]))
@@ -9,7 +9,6 @@
[app.common.data :as d]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.geom.shapes.bounds :as gsb]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]))
@@ -482,7 +481,13 @@
(if (nil? dest-frame)
[(gpt/point 0 0) [:top :left]]
(let [overlay-size (gsb/get-object-bounds objects dest-frame)
(let [;; Use the destination frame selrect (the visible frame box) to compute
;; the overlay position, not its full object bounds. Bounds include
;; padding for shadows, blur, strokes and overflowing children, which
;; would make centered/right/bottom positions off by half that padding
;; (the visible frame ends up shifted). The viewer reserves the bounds
;; size and re-aligns the selrect separately (see viewer/calculate-delta).
overlay-size (:selrect dest-frame)
base-frame-size (:selrect base-frame)
relative-to-shape-size (:selrect relative-to-shape)
relative-to-adjusted-to-base-frame {:x (- (:x relative-to-shape-size) (:x base-frame-size))
@@ -146,3 +146,24 @@
;; (app.common.pprint/pprint shape-3)
(= shape shape-3)))
{:num 200})))
(t/deftest shape-generator-key-presence
"The generator must produce the keys the schema declares required, even when
nilable. This is a targeted check for the attributes added to
`schema:shape-generic-attrs` and `schema:nilable-geom-attrs`."
(let [shapes (sg/sample (sg/generator schema:shape) {:size 200})
by-type (group-by :type shapes)]
;; All shapes: rotation, flip-x, flip-y are base record fields, always
;; present (possibly nil).
(doseq [shape shapes]
(t/is (contains? shape :rotation) "missing :rotation")
(t/is (contains? shape :flip-x) "missing :flip-x")
(t/is (contains? shape :flip-y) "missing :flip-y"))
;; Bool and path: x/y/width/height are required-but-nilable in the
;; schema. The generator must produce them (nil is a valid value).
(doseq [shape (concat (get by-type :bool [])
(get by-type :path []))]
(t/is (contains? shape :x) "bool/path missing :x")
(t/is (contains? shape :y) "bool/path missing :y")
(t/is (contains? shape :width) "bool/path missing :width")
(t/is (contains? shape :height) "bool/path missing :height"))))
@@ -10,6 +10,7 @@
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.geom.shapes :as gsh]
[app.common.geom.shapes.bounds :as gsb]
[app.common.math :as mth]
[app.common.types.shape :as cts]
[app.common.types.shape.interactions :as ctsi]
@@ -1078,3 +1079,49 @@
[overlay-pos snap] (ctsi/calc-overlay-position frame-relative base-frame objects base-frame base-frame overlay-frame frame-offset)]
(t/is (= (gpt/point 18 22) overlay-pos))
(t/is (= [:top :left] snap))))))
(t/deftest calc-overlay-position-ignores-filter-bounds
;; Regression for #9048: the overlay position must be computed from the
;; destination frame selrect (the visible frame box), not from its
;; filter-inflated object bounds. Shadows, blur, strokes or overflowing
;; children make get-object-bounds larger than the selrect, which used to
;; shift centered/right/bottom overlays by half that extra padding (the
;; overlay appeared offset, e.g. "a bit to the left").
(let [base-frame (cts/setup-shape {:type :frame :width 100 :height 100})
overlay-plain (cts/setup-shape {:type :frame :width 30 :height 20})
;; same selrect as overlay-plain, but with a drop shadow that widens
;; and heightens its object bounds well beyond the selrect.
overlay-shadow (-> (cts/setup-shape {:type :frame :width 30 :height 20})
(assoc :shadow [{:style :drop-shadow
:offset-x 0 :offset-y 0
:spread 10 :blur 0 :hidden false}]))
objects {(:id base-frame) base-frame
(:id overlay-plain) overlay-plain
(:id overlay-shadow) overlay-shadow}
frame-offset (gpt/point 5 5)
interaction (-> ctsi/default-interaction
(ctsi/set-action-type :open-overlay)
(ctsi/set-position-relative-to (:id base-frame)))]
;; Precondition: the shadow really does inflate the object bounds, so the
;; assertions below are meaningful (otherwise the test would be vacuous).
(t/is (> (:width (gsb/get-object-bounds objects overlay-shadow))
(:width (:selrect overlay-shadow))))
(t/is (> (:height (gsb/get-object-bounds objects overlay-shadow))
(:height (:selrect overlay-shadow))))
;; For every position type that depends on the overlay size, the computed
;; position must be identical whether or not the destination frame has a
;; bounds-inflating shadow.
(doseq [pos-type [:center :top-center :top-right :bottom-center :bottom-right]]
(let [i-plain (-> interaction
(ctsi/set-destination (:id overlay-plain))
(ctsi/set-overlay-pos-type pos-type base-frame objects))
i-shadow (-> interaction
(ctsi/set-destination (:id overlay-shadow))
(ctsi/set-overlay-pos-type pos-type base-frame objects))
[pos-plain snap-plain] (ctsi/calc-overlay-position i-plain base-frame objects base-frame base-frame overlay-plain frame-offset)
[pos-shadow snap-shadow] (ctsi/calc-overlay-position i-shadow base-frame objects base-frame base-frame overlay-shadow frame-offset)]
(t/testing (str "overlay position ignores filter bounds for " pos-type)
(t/is (= pos-plain pos-shadow))
(t/is (= snap-plain snap-shadow)))))))
+19 -7
View File
@@ -419,16 +419,28 @@ After creating or modifying this file, **reload the browser** (no need to restar
### Backend flags via PENPOT_FLAGS
Backend feature flags are controlled through the `PENPOT_FLAGS` environment
variable using the same `enable-<flag>` / `disable-<flag>` format. You can set
this in the `docker/devenv/docker-compose.yaml` file under the `main` service
`environment` section:
variable using the same `enable-<flag>` / `disable-<flag>` format. The devenv
sets its own list in `backend/scripts/_env`.
```yaml
environment:
- PENPOT_FLAGS=enable-access-tokens enable-mcp
To change that list for your checkout, create `backend/scripts/_env.local`.
`backend/scripts/start-dev` sources it immediately after `_env`, and the file
is gitignored, so your override never appears in `git status`:
```bash
export PENPOT_FLAGS="$PENPOT_FLAGS enable-access-tokens enable-mcp"
```
This requires **restarting the backend** to take effect.
Flags are applied left to right and the last entry wins, so appending to
`$PENPOT_FLAGS` both adds flags and switches off ones that `_env` enables:
`disable-demo-users` at the end turns off the demo users that `_env` enables
earlier.
Setting `PENPOT_FLAGS` in the container environment does not work for this,
because `_env` expands the inherited value *before* its own list. Any flag it
sets afterwards wins over yours.
This requires **restarting the backend** to take effect: stop the process in
the `backend` tmux window and run `./scripts/start-dev` again.
> **Note**: Some features (e.g., access tokens, webhooks) need both frontend and
> backend flags enabled to work end-to-end. The frontend flag enables the UI, while
+1
View File
@@ -33,6 +33,7 @@
"watch:app": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs watch main",
"watch": "pnpm run watch:app",
"build:app": "clojure -M:dev:shadow-cljs release main",
"build:wasm": "../render-wasm/build export",
"build": "pnpm run clear:shadow-cache && pnpm run build:app",
"fmt": "cljfmt fix --parallel=true src/",
"check-fmt": "cljfmt check --parallel=true src/",
+14
View File
@@ -8,6 +8,17 @@ export NODE_ENV=production;
corepack enable;
corepack install || exit 1;
pnpm install || exit 1;
pnpm run build:wasm;
WASM_SRC="resources/wasm";
WASM_SHARED="src/app/wasm/shared.js";
if [ ! -f "$WASM_SRC/render-wasm.wasm" ] || [ ! -f "$WASM_SHARED" ]; then
echo "ERROR: the render-wasm build did not produce:" >&2;
echo " $WASM_SRC/render-wasm.wasm" >&2;
echo " $WASM_SHARED" >&2;
exit 1;
fi
rm -rf target
# Build the application
@@ -18,6 +29,9 @@ cp pnpm-workspace.yaml target/;
cp package.json target/;
touch target/pnpm-workspace.yaml;
mkdir -p target/$WASM_SRC;
cp "$WASM_SRC/render-wasm.js" "$WASM_SRC/render-wasm.wasm" target/$WASM_SRC/;
cat <<EOF | tee target/setup
#/usr/bin/env bash
set -e;
+7
View File
@@ -12,6 +12,7 @@
[app.config :as cf]
[app.http :as http]
[app.redis :as redis]
[app.wasm :as wasm]
[promesa.core :as p]))
(enable-console-print!)
@@ -23,6 +24,12 @@
:public-uri (str (cf/get :public-uri))
:internal-uri (str (cf/get-internal-uri))
:version (:full cf/version))
(when (contains? cf/flags :wasm-export)
(l/warn :msg "headless wasm export enabled (experimental)"
:hint (str "renders run in-process on a single shared wasm module, "
"one at a time; not recommended for busy instances")
:wasm-dir wasm/artifact-dir
:image-cache-mb wasm/image-cache-mb))
(p/do!
(bwr/init)
(redis/init)
+19 -7
View File
@@ -7,10 +7,13 @@
(ns app.renderer
"Common renderer interface."
(:require
[app.common.logging :as l]
[app.common.spec :as us]
[app.config :as cf]
[app.renderer.bitmap :as rb]
[app.renderer.pdf :as rp]
[app.renderer.svg :as rs]
[app.renderer.wasm :as rw]
[cljs.spec.alpha :as s]))
(s/def ::name ::us/string)
@@ -36,13 +39,22 @@
:opt-un [::is-wasm]))
(defn render
[{:keys [type] :as params} on-object]
[{:keys [type is-wasm] :as params} on-object]
(us/verify ::render-params params)
(us/verify fn? on-object)
(case type
:png (rb/render params on-object)
:jpeg (rb/render params on-object)
:webp (rb/render params on-object)
:pdf (rp/render params on-object)
:svg (rs/render params on-object)))
(let [wasm-export? (contains? cf/flags :wasm-export)
headless? (and is-wasm wasm-export? (not= :svg type))]
(when is-wasm
(l/info :hint "render"
:type type
:wasm-export wasm-export?
:backend (if headless? "wasm" "browser")))
(if headless?
(rw/render params on-object)
(case type
:png (rb/render params on-object)
:jpeg (rb/render params on-object)
:webp (rb/render params on-object)
:pdf (rp/render params on-object)
:svg (rs/render params on-object)))))
+451
View File
@@ -0,0 +1,451 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.renderer.wasm
"Headless renderer backend: renders exports with the render-wasm Skia
pipeline in this Node process, with no browser and no WebGL.
Per request: fetch scene (get-page RPC) -> serialize -> provision fonts and
images -> relayout text with the real fonts -> render each object.
One shared WASM design state, so requests are serialized one at a time.
Handles png/jpeg/webp (Skia encodes all three) and pdf; `:svg` stays on the
browser path."
(:require
["node:fs" :as fs]
["undici" :as http]
[app.common.data :as d]
[app.common.fonts :as cfnt]
;; Required for side effects: these register the transit read handlers and
;; deftype impls the `get-page` response is decoded into.
[app.common.geom.matrix]
[app.common.geom.point]
[app.common.geom.rect]
[app.common.logging :as l]
[app.common.transit :as t]
[app.common.types.fills.impl]
[app.common.types.objects-map]
[app.common.types.path.impl]
[app.common.types.shape]
[app.common.types.shape.images :as images]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.util.mime :as mime]
[app.util.shell :as sh]
[app.wasm :as wasm]
[app.wasm.serialize :as serialize]
[cuerdas.core :as str]
[promesa.core :as p]))
;; --- module lifecycle (one shared, lazily-initialized instance)
(defonce ^:private module* (atom nil))
(defn- ensure-module!
[]
(or @module*
(reset! module* (wasm/init!))))
;; --- serialized access to the shared module
;;
;; `handle-multiple-export` fans out partitions concurrently, but there is one
;; design state and one global mem buffer, so their serialize/render/alloc must
;; not interleave.
(defonce ^:private queue (atom (p/resolved nil)))
(defn- enqueue!
"Runs `thunk` (0-arg, returns a promise) only after all previously enqueued
work has settled. Returns `thunk`'s promise. A task's failure is isolated:
it doesn't break the chain for the next task."
[thunk]
(let [result (p/handle @queue (fn [_ _] (thunk)))]
(reset! queue (p/handle result (fn [_ _] nil)))
result))
;; --- backend endpoints
;;
;; Every fetch targets the internal endpoint (falling back to public-uri),
;; in a deployment the exporter reaches the backend over the container network
(defn- internal-uri
"Absolute URI for `path` on the internal (backend) endpoint."
[path]
(-> (cf/get-internal-uri)
(u/ensure-path-slash)
(u/join path)
(str)))
(defn- error-detail
"Node's fetch reports every transport failure as a bare `TypeError: fetch
failed`; the actual reason (TLS rejection, DNS, ECONNREFUSED) is buried in a
nested `cause` chain that the logger does not print. Flattens the chain into
one readable string."
[cause]
(->> (iterate (fn [^js e] (unchecked-get e "cause")) cause)
(take-while some?)
(take 5)
(map (fn [^js e]
(let [code (unchecked-get e "code")
msg (or (unchecked-get e "message") (str e))]
(if code (str code ": " msg) msg))))
(str/join " <- ")))
(defn- fetch!
"`undici/fetch` that fails with an ex-info carrying the target uri and the
unwrapped cause chain, so a failed request says what actually went wrong and
against which endpoint."
[uri opts]
(->> (p/do (http/fetch uri opts))
(p/merr (fn [cause]
(p/rejected (ex-info "http fetch failed"
{:uri uri :detail (error-detail cause)}
cause))))))
(defn- explain
"Log-friendly reason for `cause`: the detail `fetch!` already attached, or a
freshly unwrapped chain for anything else (WASM aborts, decode errors)."
[cause]
(or (:detail (ex-data cause))
(error-detail cause)))
(defn- rpc-headers
"Auth headers for backend RPC calls (management key + bearer)."
[token]
#js {"Content-Type" "application/transit+json"
"X-Shared-Key" (str "exporter " cf/management-key)
"Authorization" (str "Bearer " token)})
(defn- asset-headers
"Auth headers for `/assets/*`. Cookie, not Bearer: those endpoints redirect to
a presigned S3/minio URL, and a Bearer header makes S3 400 (\"multiple
authentication types\")."
[token]
#js {"X-Shared-Key" (str "exporter " cf/management-key)
"Cookie" (str "auth-token=" token)})
;; --- shape bundle fetch (backend RPC)
(defn- fetch-objects
"Fetches the exported roots and their children from the backend via the
`get-page` RPC (`:object-id`, as the browser render path does), using the
same auth the exporter uses elsewhere (management key + bearer)."
[{:keys [file-id page-id share-id token objects]}]
(let [headers (rpc-headers token)
root-ids (into #{} (map :id) objects)
body (t/encode-str (cond-> {:file-id file-id
:page-id page-id}
(seq root-ids) (assoc :object-id root-ids)
share-id (assoc :share-id share-id)))
uri (internal-uri "api/rpc/command/get-page")]
(l/dbg :hint "wasm render: get-page"
:uri uri
:file-id (str file-id)
:page-id (str page-id)
:roots (count root-ids))
(->> (fetch! uri #js {:method "POST" :headers headers :body body})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.text resp)
(->> (.text resp)
(p/mcat (fn [resp-body]
(l/error :hint "wasm render: get-page failed"
:uri uri
:status (.-status resp)
:body resp-body)
(p/rejected (ex-info "get-page failed"
{:status (.-status resp)
:body resp-body}))))))))
(p/fmap t/decode-str)
(p/fmap :objects))))
;; --- font resolution
;;
;; The text serializer keeps each font's real uuid, so `wasm/fonts-for-shape`
;; reports it. Custom (team) fonts resolve through the file's font variants,
;; google fonts through the shared `app.common.fonts` catalog; builtin
;; fonts through its bundled family + the frontend's static `/fonts/`.
(defn- fetch-font-variants
"Team (custom) font variants for the file, or nil — a failure here degrades
to fallback fonts, it does not fail the export."
[{:keys [file-id share-id token]}]
(let [headers (rpc-headers token)
body (t/encode-str (cond-> {:file-id file-id}
share-id (assoc :share-id share-id)))
uri (internal-uri "api/rpc/command/get-font-variants")]
(->> (fetch! uri #js {:method "POST" :headers headers :body body})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.text resp)
(p/resolved nil))))
(p/fmap (fn [s] (when s (t/decode-str s))))
(p/merr (fn [cause]
(l/warn :hint "wasm render: get-font-variants failed"
:uri uri :detail (explain cause) :cause cause)
(p/resolved nil))))))
(defn- fetch-ttf-bytes
"Downloads a TTF, returning a promise of an ArrayBuffer (or nil). A failure
here degrades to fallback fonts, it does not fail the export."
([uri] (fetch-ttf-bytes uri #js {:method "GET"}))
([uri opts]
(->> (fetch! uri opts)
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.arrayBuffer resp)
(p/resolved nil))))
(p/merr (fn [cause]
(l/warn :hint "wasm render: font fetch failed"
:uri uri :detail (explain cause) :cause cause)
(p/resolved nil))))))
;; TTF bytes cached for the process lifetime, keyed by whatever identifies the
;; variant (a gfont id+weight+style, a builtin file name).
(defonce ^:private font-bytes* (atom {}))
(defn- cached-ttf-bytes
[cache-key fetch-fn]
(if-let [bytes (get @font-bytes* cache-key)]
(p/resolved bytes)
(->> (fetch-fn)
(p/fmap (fn [buf]
(when buf (swap! font-bytes* assoc cache-key buf))
buf)))))
(defn- fetch-asset-bytes
[asset-id {:keys [token]}]
(fetch-ttf-bytes (internal-uri (str "assets/by-id/" asset-id))
#js {:method "GET" :headers (asset-headers token)}))
(defn- fetch-gfont-bytes
[ttf-url]
(fetch-ttf-bytes (cfnt/gstatic->proxy-url ttf-url (internal-uri "internal/gfonts/font"))))
(defn- fetch-builtin-font-bytes
[ttf-file]
(cached-ttf-bytes ttf-file #(fetch-ttf-bytes (internal-uri (str "fonts/" ttf-file)))))
(defn- make-resolve-font
"Builds a `resolve-font` fn (family map -> promise of TTF bytes). Custom
variants first, matching uuid+weight+style then degrading to uuid+weight then
uuid; the bundled fonts for `uuid/zero`, which is what `font-id->uuid` maps
every builtin family to; google catalog otherwise."
[variants params]
(fn [{:keys [id weight style]}]
(let [font-uuid (uuid/from-unsigned-parts (aget id 0) (aget id 1) (aget id 2) (aget id 3))
style-str (if (zero? style) "normal" "italic")
variant (or (d/seek (fn [v] (and (= (:font-id v) font-uuid)
(= (:font-weight v) weight)
(= (name (:font-style v)) style-str)))
variants)
(d/seek (fn [v] (and (= (:font-id v) font-uuid)
(= (:font-weight v) weight)))
variants)
(d/seek (fn [v] (= (:font-id v) font-uuid)) variants))]
(cond
(:ttf-file-id variant)
(fetch-asset-bytes (:ttf-file-id variant) params)
(= uuid/zero font-uuid)
(fetch-builtin-font-bytes (cfnt/resolve-ttf-file weight style))
:else
(if-let [gurl (cfnt/resolve-ttf-url font-uuid weight style)]
(fetch-gfont-bytes gurl)
(p/resolved nil))))))
;; --- fallback fonts (emoji + per-script noto fonts)
;;
;; Emoji and non-latin scripts render through fallback families, not through
;; any span's font family, so `wasm/fonts-for-shape` never reports them and the
;; provisioning above never uploads them. Must run per request, since
;; `clear-fonts!` empties the store; the TTF bytes stay cached per process.
(defn- scene-fallback-fonts
"Fallback font descriptors needed by the scene's text. Deduped because
several languages map to one noto family and provisioning is concurrent —
otherwise they all miss the byte cache at once and refetch the same TTF."
[scene]
(let [texts (for [shape (vals scene)
:when (= :text (:type shape))
node (or (some->> (:content shape) (tree-seq :children :children)) [])
:let [text (:text node)]
:when (string? text)]
text)
emoji? (boolean (some cfnt/contains-emoji? texts))
langs (reduce cfnt/collect-used-languages #{} texts)]
(distinct
(cond-> (cfnt/add-noto-fonts [] langs)
emoji? (cfnt/add-emoji-font)))))
(defn- fetch-fallback-font-bytes
"Downloads one fallback font's TTF. Cached by the whole variant, not just
`font-id`: `resolve-ttf-url` picks a different TTF per weight/style, so a
font-id-only key would serve the first downloaded variant for every other one."
[{:keys [font-id weight style]}]
(if-let [ttf-url (some-> (cfnt/gfont-id->uuid font-id) (cfnt/resolve-ttf-url weight style))]
(cached-ttf-bytes [font-id weight style] #(fetch-gfont-bytes ttf-url))
(p/resolved nil)))
(defn- provision-fallback-fonts!
[scene]
(->> (scene-fallback-fonts scene)
(map (fn [{:keys [font-id weight style is-emoji is-fallback] :as font}]
(if-let [font-uuid (cfnt/gfont-id->uuid font-id)]
(->> (fetch-fallback-font-bytes font)
(p/fmap (fn [buf]
(if buf
(wasm/store-font! {:id (uuid/get-u32 font-uuid)
:weight weight
:style style
:emoji? (boolean is-emoji)
:fallback? (boolean is-fallback)}
buf)
(l/warn :hint "wasm render: fallback font unavailable"
:font-id font-id)))))
(p/resolved nil))))
(p/all)))
;; --- image resolution
;;
;; Image fills reference file-media ids; the encoded bytes go straight to
;; `_store_image` (Skia decodes, no WebGL), keyed by media uuid so this happens
;; once per request rather than per rendered object.
(defn- fetch-file-media-bytes
"Downloads an image fill's encoded bytes by file-media id."
[media-id {:keys [token]}]
(let [headers (asset-headers token)
uri (internal-uri (str "assets/by-file-media-id/" media-id))]
(->> (fetch! uri #js {:method "GET" :headers headers})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.arrayBuffer resp)
(do
(l/warn :hint "wasm render: image fetch non-200"
:media-id (str media-id)
:uri uri
:status (.-status resp))
(p/resolved nil)))))
(p/merr (fn [cause]
(l/warn :hint "wasm render: image fetch failed"
:media-id (str media-id) :uri uri
:detail (explain cause) :cause cause)
(p/resolved nil))))))
(defn- provision-images!
"Fetches and stores every image the scene references (shape, stroke and
text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts,
the image store is not reset per request, so already-held images are skipped
and repeated exports of a file reuse them."
[scene params]
(let [all-ids (images/scene-image-ids scene)
new-ids (remove wasm/image-cached? all-ids)]
(l/dbg :hint "wasm render: provisioning images"
:total (count all-ids)
:cached (- (count all-ids) (count new-ids)))
(->> new-ids
(map (fn [image-id]
(->> (fetch-file-media-bytes image-id params)
(p/fmap (fn [buf]
(if buf
(do
(l/dbg :hint "wasm render: image stored"
:media-id (str image-id)
:bytes (.-byteLength ^js buf))
(wasm/store-image! image-id buf))
(l/warn :hint "wasm render: image unavailable"
:media-id (str image-id))))))))
(p/all))))
(defn- relayout-text!
"Recomputes layout for every text shape, once the real fonts are provisioned
(serialize-time layout used the fallback)."
[scene]
(doseq [shape (vals scene)
:when (= :text (:type shape))]
(wasm/update-text-layout! (:id shape))))
;; --- render
(defn- render-object-bytes
[type id scale]
(if (= :pdf type)
(let [bytes (wasm/render-shape-pdf id scale)]
(l/dbg :hint "PDF generated via Skia (render-wasm headless)"
:object-id (str id)
:backend "skia-wasm"
:bytes (.-length bytes))
bytes)
(wasm/render-shape-raster id scale type)))
(defn- render*
[{:keys [scale type objects] :as params} on-object]
(l/dbg :hint "wasm render: start"
:type type
:scale scale
:objects (count objects)
:file-id (str (:file-id params))
:page-id (str (:page-id params)))
(->> (ensure-module!)
(p/mcat (fn [_] (fetch-objects params)))
(p/mcat (fn [scene]
(l/dbg :hint "wasm render: scene fetched" :shapes (count scene))
(serialize/serialize-scene! scene)
(l/dbg :hint "wasm render: scene serialized")
;; So fonts from a previous request don't leak into this one.
(wasm/clear-fonts!)
(->> (p/all [(fetch-font-variants params)
(provision-images! scene params)
(provision-fallback-fonts! scene)])
(p/mcat
(fn [[variants _]]
(let [resolve-font (make-resolve-font (or variants []) params)]
;; Before rendering, so the relayout below sees real
;; font metrics. Deduped across objects: a partition
;; sharing one family downloads its TTF once.
(wasm/provision-fonts! (map :id objects) resolve-font))))
(p/mcat
(fn [_]
(relayout-text! scene)
(p/run
(fn [{:keys [id] :as object}]
(let [bytes (render-object-bytes type id scale)
path (sh/tempfile :prefix "penpot.tmp.wasm."
:suffix (mime/get-extension type))]
(l/dbg :hint "wasm render: object rendered"
:object-id (str id) :bytes (.-length bytes))
(fs/writeFileSync path bytes)
;; `on-object` returns a plain value (zip append) or
;; a promise (single export's file move); `p/do`
;; normalizes both to a thenable.
(p/do (on-object (assoc object :path path)))))
objects))))))
(p/fmap (fn [result]
;; After the request, never mid-render, so an image can't
;; disappear under a running export.
(let [evicted (wasm/evict-images! wasm/image-cache-mb)]
(when (pos? evicted)
(l/info :hint "wasm render: evicted cached images" :count evicted)))
result))
(p/merr (fn [cause]
(l/error :hint "wasm render: failed"
:detail (explain cause)
:internal-uri (str (cf/get-internal-uri))
:cause cause)
;; A panic can leave the mem buffer allocated or the instance
;; aborted; drop it so the next request rebuilds a fresh one.
(reset! module* nil)
(p/rejected cause)))))
(defn render
"Public entry. `enqueue!` keeps concurrent exports off each other's toes on
the shared WASM instance."
[params on-object]
(enqueue! (fn [] (render* params on-object))))
+255
View File
@@ -0,0 +1,255 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.wasm
"Headless driver for the render-wasm module under Node: the GPU-free
counterpart of `app.render-wasm.api`. Loads the emscripten artifact, boots it
via `init_headless`, and exposes font provisioning + shape rendering.
Serialization is reused from the portable render-wasm leaves, so this
namespace owns only the Node runtime and the headless render calls.
Requires render-wasm built with `-sENVIRONMENT=web,node`."
(:require
["node:fs" :as fs]
["node:path" :as path]
[app.common.data :as d]
[app.common.logging :as l]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.wasm :as wasm]
[app.common.uuid :as uuid]
;; Required for side effects: binds the generated enums.
[app.wasm.enums]
[promesa.core :as p]
[shadow.esm :refer [dynamic-import]]))
(def ^:private default-viewport-width 1920)
(def ^:private default-viewport-height 1080)
;; render_shape_raster / render_shape_pixels result header: [len u32][w u32][h u32].
(def ^:private RASTER-HEADER-BYTES 12)
;; render_shape_pdf result header: [len u32] only.
(def ^:private PDF-HEADER-BYTES 4)
;; get_fonts_for_shape entry: [uuid 16 bytes][weight u32][style u32].
(def ^:private FONT-ENTRY-BYTES 24)
(def artifact-dir
"Built render-wasm artifact, relative to the process working directory. Same
path in devenv and inside the bundle, so it is a constant."
"resources/wasm")
(def image-cache-mb
"Byte budget (MB) the image store is trimmed to between requests."
256)
(defn- read-result-bytes
"Reads `len` bytes from the WASM heap starting at `offset`, copying them out
(via `.slice`) before the buffer is freed."
[offset len]
(.slice (mem/get-heap-u8) offset (+ offset len)))
;; --- MODULE LIFECYCLE
(defn init!
"Loads the render-wasm artifact under Node and boots it headless. Sets the
shared `wasm/internal-module` so the portable serialization leaves work.
Idempotent-ish: callers should hold the returned module."
([] (init! default-viewport-width default-viewport-height))
([width height]
(let [dir artifact-dir
js-path (path/resolve dir "render-wasm.js")
wasm-path (path/resolve dir "render-wasm.wasm")
wasm-bytes (fs/readFileSync wasm-path)]
(l/info :hint "loading render-wasm (headless)" :js js-path)
;; shadow-cljs :esm — use its dynamic-import helper (raw `js/import`
;; compiles to an undefined `import$`).
(->> (dynamic-import (str "file://" js-path))
(p/mcat
(fn [mod]
(let [factory (unchecked-get mod "default")]
(factory
#js {;; Bypass the web fetch loader: instantiate from local bytes.
:instantiateWasm
(fn [imports success]
(-> (js/WebAssembly.instantiate wasm-bytes imports)
(.then (fn [result] (success (.-instance result)))))
#js {})
:locateFile (fn [p] (path/resolve dir p))
:printErr (fn [s] (l/warn :wasm s))}))))
(p/fmap
(fn [module]
(set! wasm/internal-module module)
(h/call module "_init_headless" width height)
(set! wasm/context-initialized? true)
(l/info :hint "render-wasm headless module ready" :width width :height height)
module))))))
;; --- FONT PROVISIONING (on demand, mirrors the browser)
(defn fonts-for-shape
"Returns the distinct font families needed to render the subtree rooted at
`shape-id` as a vector of {:id <uuid-u32x4> :weight :style}. Equivalent to
the browser's `get-content-fonts`, but read from the loaded WASM tree."
[shape-id]
(let [module wasm/internal-module
buf (uuid/get-u32 shape-id) ;; resolved from app.render-wasm leaves
offset (h/call module "_get_fonts_for_shape"
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3))
heap32 (mem/get-heap-u32)
n (aget heap32 (mem/->offset-32 offset))
;; `vec` must stay eager: it reads the result buffer, and the
;; `mem/free` below invalidates these offsets.
entries (vec
(for [i (range n)]
(let [base (+ offset 4 (* i FONT-ENTRY-BYTES))
u32 (fn [o] (aget heap32 (mem/->offset-32 (+ base o))))]
{:id #js [(u32 0) (u32 4) (u32 8) (u32 12)]
:weight (u32 16)
:style (u32 20)})))]
(mem/free)
entries))
(defn- font-key
"Value key for a family map. Its `:id` is a JS array, so the map itself can't
be compared by value."
[{:keys [id weight style]}]
[(aget id 0) (aget id 1) (aget id 2) (aget id 3) weight style])
(defn fonts-for-shapes
"Distinct font families needed by every subtree in `shape-ids`. Objects in a
partition overwhelmingly share families, so deduping here means one download
and one `_store_font` per family rather than one per object."
[shape-ids]
(into [] (comp (mapcat fonts-for-shape)
(d/distinct-xf font-key))
shape-ids))
(defn store-font!
"Uploads one font's TTF bytes into the WASM font store, keyed by the family
(uuid quartet + weight + style). `font-bytes` is a Uint8Array/Buffer.
Does NOT call `mem/free` — `store_font` (and likewise `store_image` below)
releases the global buffer itself on the Rust side. Freeing again here would
drop a buffer a later writer already owns."
[{:keys [id weight style emoji? fallback?]} font-bytes]
(let [module wasm/internal-module
size (.-byteLength font-bytes)
ptr (h/call module "_alloc_bytes" size)
heap (mem/get-heap-u8)]
(.set heap (js/Uint8Array. font-bytes) ptr)
(h/call module "_store_font"
(aget id 0) (aget id 1) (aget id 2) (aget id 3)
weight style (boolean emoji?) (boolean fallback?))))
(defn clear-fonts!
"Resets the WASM font store. Must be called once per render request because
the shared module would otherwise accumulate fonts across requests."
[]
(h/call wasm/internal-module "_clear_fonts"))
(defn update-text-layout!
"Recomputes a text shape's layout with the currently provisioned fonts. Text is
laid out at serialize time using the fallback font (real fonts aren't uploaded
yet), so this must run again after `provision-fonts!` or glyph metrics/line
breaks are wrong."
[shape-id]
(let [buf (uuid/get-u32 shape-id)]
(h/call wasm/internal-module "_update_shape_text_layout_for"
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3))))
(defn image-cached?
"True when the module's image store already holds this image (full size).
The store is NOT reset between requests, so previously provisioned images
can be reused instead of refetched."
[image-id]
(let [buf (uuid/get-u32 image-id)]
(not (zero? (h/call wasm/internal-module "_is_image_cached"
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
false)))))
(defn store-image!
"Uploads one image's *encoded* bytes (PNG/JPEG — Skia decodes, no WebGL) into
the WASM image store via `_store_image`. Buffer layout matches the Rust reader:
[shape uuid 16][image uuid 16][is_thumbnail u32][encoded bytes]. Images are
keyed by image uuid, so the shape uuid is left zero. `image-bytes` is an
ArrayBuffer/Buffer/Uint8Array."
[image-id image-bytes]
(let [module wasm/internal-module
img-u8 (js/Uint8Array. image-bytes)
size (.-byteLength img-u8)
total (+ 36 size)
ptr (h/call module "_alloc_bytes" total)
heap (mem/get-heap-u8)
dview (js/DataView. (.-buffer heap))
quart (uuid/get-u32 image-id)]
;; shape uuid [0..16) = 0 (images are keyed by image uuid only)
(.setUint32 dview (+ ptr 0) 0 true)
(.setUint32 dview (+ ptr 4) 0 true)
(.setUint32 dview (+ ptr 8) 0 true)
(.setUint32 dview (+ ptr 12) 0 true)
;; image uuid [16..32) — 4 LE u32 (matches common `buffer/write-uuid`, which
;; the fill path uses, so it hashes to the same key the fill references)
(.setUint32 dview (+ ptr 16) (aget quart 0) true)
(.setUint32 dview (+ ptr 20) (aget quart 1) true)
(.setUint32 dview (+ ptr 24) (aget quart 2) true)
(.setUint32 dview (+ ptr 28) (aget quart 3) true)
;; is_thumbnail [32..36) = 0
(.setUint32 dview (+ ptr 32) 0 true)
;; encoded bytes [36..)
(.set heap img-u8 (+ ptr 36))
(h/call module "_store_image")))
(defn evict-images!
"Evicts least-recently-used images until the store retains at most `max-mb`
megabytes. Returns the number evicted."
[max-mb]
(h/call wasm/internal-module "_evict_images_to_budget" max-mb))
(defn provision-fonts!
"Resolves and uploads every font needed by `shape-ids`, each family fetched
once. `resolve-font` is an injected fn of the family map -> promise of TTF
bytes (or nil to skip). This keeps the font *source* (gfonts proxy / custom
assets / backend) out of the driver."
[shape-ids resolve-font]
(->> (fonts-for-shapes shape-ids)
(map (fn [family]
(->> (resolve-font family)
(p/fmap (fn [bytes] (when bytes (store-font! family bytes)))))))
(p/all)))
;; --- RENDER
(defn- read-render-result
"Copies the encoded payload out of a `_render_shape_*` result buffer and frees
it. `header-bytes` is the size of the header preceding the payload."
[offset header-bytes]
(let [heap32 (mem/get-heap-u32)
len (aget heap32 (mem/->offset-32 offset))
bytes (read-result-bytes (+ offset header-bytes) len)]
(mem/free)
bytes))
(defn render-shape-raster
"Renders the shape subtree to encoded image bytes (Uint8Array) on a CPU
surface. `format` is :png, :jpeg or :webp; jpeg is flattened onto white on
the Rust side, since it has no alpha channel."
[shape-id scale format]
(let [buf (uuid/get-u32 shape-id)]
(-> (h/call wasm/internal-module "_render_shape_raster"
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
scale (sr/translate-raster-format format))
(read-render-result RASTER-HEADER-BYTES))))
(defn render-shape-pdf
"Renders the shape subtree to PDF bytes (Uint8Array)."
[shape-id scale]
(let [buf (uuid/get-u32 shape-id)]
(-> (h/call wasm/internal-module "_render_shape_pdf"
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
scale)
(read-render-result PDF-HEADER-BYTES))))
+19
View File
@@ -0,0 +1,19 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.wasm.enums
"Binds this build's generated enums into the shared bridge.
`shared.js` is emitted next to this file by `render-wasm/build export` and is
not committed. Requiring this namespace is what makes
`app.common.render-wasm.wasm/serializers` usable."
(:require
["./shared.js" :as shared]
[app.common.render-wasm.wasm :as wasm])
(:require-macros
[app.common.render-wasm.enums :as enums]))
(wasm/init-serializers! (enums/serializers shared))
+46
View File
@@ -0,0 +1,46 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.wasm.serialize
"Browser-free shape serialization for the headless exporter: the counterpart
of `app.render-wasm.api/set-object`, which cannot be reused directly because
its namespace pulls React/DOM/store. Only the call sequencing lives here —
every byte layout comes from the shared serializers, so the bytes sent to
WASM are the editor's.
Covers everything except svg-raw. Image bytes and fonts are provisioned
separately by `app.renderer.wasm`."
(:require
[app.common.render-wasm.api.props :as props]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.serialize-shape :as serialize-shape]
[app.common.render-wasm.wasm :as wasm]
[app.wasm.text :as text]))
(defn set-shape!
"Serializes a single shape into the WASM design state. The host-independent
properties (base props, children, blur, shadows, svg-attrs, mask, bool-type,
path geometry, grow-type) go through the shared `serialize-shape!` — the same
code the workspace's `set-object` uses, so the two can't drift. Only the
host-specific parts are handled here: fills/strokes (image bytes are provisioned
separately) and text content (fonts provisioned separately)."
[shape]
(let [type (get shape :type)]
(serialize-shape/serialize-shape! shape)
(props/write-shape-fills! (get shape :fills))
(when-not (= type :group)
(props/write-shape-strokes! (get shape :strokes)))
(when (= type :text)
(text/set-shape-text! (get shape :content)))))
(defn serialize-scene!
"Loads every shape of an `objects` map into the WASM design state. Resets the
shapes pool first so repeated exports don't accumulate into the shared
state. Order is irrelevant: shapes reference each other by id and the tree
is resolved at render time."
[objects]
(h/call wasm/internal-module "_init_shapes_pool" (count objects))
(run! set-shape! (vals objects)))
+35
View File
@@ -0,0 +1,35 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.wasm.text
"Browser-free text-content serialization for the headless exporter. Only the
paragraph walk is local: the binary layout and the font-id -> uuid mapping
both come from `app.common.render-wasm.text-content`."
(:require
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.text-content :as tc]
[app.common.render-wasm.wasm :as wasm]))
(defn set-shape-text!
"Serializes a text shape's content into the current WASM shape. Mirrors the
editor's sequence: clear -> vertical-align -> append each paragraph -> layout.
Byte writing and font resolution are the shared
`text-content/write-shape-text!` defaults; the exporter has no fonts DB, so
it injects no variant normalization."
[content]
(when content
(h/call wasm/internal-module "_clear_shape_text")
(h/call wasm/internal-module "_set_shape_vertical_align"
(sr/translate-vertical-align (get content :vertical-align)))
(let [paragraph-set (first (get content :children))
paragraphs (get paragraph-set :children)]
(doseq [paragraph paragraphs]
(let [spans (get paragraph :children)]
(when (seq spans)
(let [text (apply str (map :text spans))]
(tc/write-shape-text! spans paragraph text {}))))))
(h/call wasm/internal-module "_update_shape_text_layout")))
+2 -1
View File
@@ -1,6 +1,7 @@
{:paths ["src" "vendor" "resources" "test"]
:deps
{penpot/common
{;; Carries `app.common.render-wasm.*`, shared with the headless exporter.
penpot/common
{:local/root "../common"}
org.clojure/clojure {:mvn/version "1.12.2"}
+1 -1
View File
@@ -19,7 +19,7 @@
"build:storybook": "(cd packages/ui && pnpm run build) && pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build",
"build:storybook:assets": "node ./scripts/build-storybook-assets.js",
"build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook",
"build:wasm": "../render-wasm/build",
"build:wasm": "../render-wasm/build frontend",
"build:app:libs": "node ./scripts/build-libs.js",
"build:app:main": "clojure -M:dev:shadow-cljs release main worker",
"build:app:worker": "clojure -M:dev:shadow-cljs release worker",
@@ -0,0 +1,349 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type",
"text-editor/v2"
]
},
"~:team-id": "~u9e6e22b2-db76-81d6-8006-75d7cdbb8bad",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "Fixed size text",
"~:revn": 3,
"~:modified-at": "~m1753957736516",
"~:vern": 0,
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2230",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0004-clean-shadow-color",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0007-clear-invalid-strokes-and-fills-v2",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags"
]
},
"~:version": 67,
"~:project-id": "~u9e6e22b2-db76-81d6-8006-75d7cdc30669",
"~:created-at": "~m1753957644225",
"~:data": {
"~:pages": [
"~u238a17e0-75ff-8075-8006-934586ea2231"
],
"~:pages-index": {
"~u238a17e0-75ff-8075-8006-934586ea2231": {
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 0.0,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.01
}
},
{
"~#point": {
"~:x": 0.0,
"~:y": 0.01
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": [
"~ucc6f0580-449c-8019-8006-9345db077fa0"
]
}
},
"~ucc6f0580-449c-8019-8006-9345db077fa0": {
"~#shape": {
"~:y": 150,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:content": {
"~:type": "root",
"~:key": "1s4am1jl24s",
"~:children": [
{
"~:type": "paragraph-set",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:font-id": "sourcesanspro",
"~:key": "13p0zwl2yhc",
"~:font-size": "14",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "Lorem ipsum"
}
],
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:key": "20hf3kmyoub",
"~:font-size": "14",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:type": "paragraph",
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro"
}
]
}
],
"~:vertical-align": "top"
},
"~:hide-in-viewer": false,
"~:name": "Fixed text",
"~:width": 300,
"~:type": "~:text",
"~:points": [
{
"~#point": {
"~:x": 200,
"~:y": 150
}
},
{
"~#point": {
"~:x": 500,
"~:y": 150
}
},
{
"~#point": {
"~:x": 500,
"~:y": 350
}
},
{
"~#point": {
"~:x": 200,
"~:y": 350
}
}
],
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~ucc6f0580-449c-8019-8006-9345db077fa0",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:x": 200,
"~:selrect": {
"~#rect": {
"~:x": 200,
"~:y": 150,
"~:width": 300,
"~:height": 200,
"~:x1": 200,
"~:y1": 150,
"~:x2": 500,
"~:y2": 350
}
},
"~:flip-x": null,
"~:height": 200,
"~:flip-y": null
}
}
},
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2231",
"~:name": "Page 1"
}
},
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2230",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}
@@ -9,7 +9,9 @@ const FILE = {
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
// WASM_FLAGS already enables render-wasm; add the WASM text editor on top.
await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-wasm"]);
await WasmWorkspacePage.mockConfigFlags(page, [
"enable-feature-text-editor-wasm",
]);
});
async function openEditorAndSelectAll(workspace) {
@@ -21,13 +23,56 @@ async function openEditorAndSelectAll(workspace) {
await workspace.page.keyboard.press("ControlOrMeta+a");
}
test("Typography at a collapsed caret only styles newly typed text", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
const fontSize = workspace.textEditor.fontSize;
const editorInput = page.locator("#text-editor-wasm-input");
// Draw a text box, focus it, and type some text; the caret ends up collapsed
// after it.
await workspace.createTextShape(200, 150, 460, 260);
await workspace.clickAt(210, 160);
await expect(editorInput).toBeFocused();
await page.keyboard.type("ab");
const originalSize = await fontSize.inputValue();
const newSize = String(Number(originalSize) + 20);
// Change the font size with a collapsed caret. This must not restyle the
// existing text; it is stashed as a pending style for the next input. Focus
// returns to the editor once the sidebar input blurs.
await workspace.textEditor.changeFontSize(newSize);
await expect(editorInput).toBeFocused();
// Typing now adopts the pending size as its own span.
await page.keyboard.type("X");
// The just-typed "X" carries the new size...
await page.keyboard.press("Shift+ArrowLeft");
await expect(fontSize).toHaveValue(newSize);
// ...while the pre-existing "ab" keeps the original size (the bug applied the
// change to the whole shape instead).
await page.keyboard.press("Home");
await page.keyboard.press("Shift+ArrowRight");
await page.keyboard.press("Shift+ArrowRight");
await expect(fontSize).toHaveValue(originalSize);
});
test.describe("BUG 10502 - Mixed families and variants", () => {
test("Multiple variants of the same font family", async ({
page,
}) => {
test("Multiple variants of the same font family", async ({ page }) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-10502-mixed-variants.json");
await workspace.mockGetFile(
"text-editor/get-file-10502-mixed-variants.json",
);
await workspace.goToWorkspace(FILE);
await workspace.waitForFirstRender();
@@ -47,10 +92,14 @@ test.describe("BUG 10502 - Mixed families and variants", () => {
await expect(fontVariant).toHaveText("--");
});
test("Mixed font families appear as such in the dropdown", async ({ page }) => {
test("Mixed font families appear as such in the dropdown", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-10502-mixed-families.json");
await workspace.mockGetFile(
"text-editor/get-file-10502-mixed-families.json",
);
// Serve a stand-in TTF for Sora so the render doesn't wait on a real fetch.
// Glyphs are irrelevant here: the assertion only inspects the sidebar.
await workspace.mockGoogleFont("sora", "render-wasm/assets/ebgaramond.ttf");
@@ -108,6 +157,69 @@ test.describe("BUG 10530 - Empty text box left behind when leaving the editor",
});
});
test.describe("BUG 11083 - Changing typography must not quit the editor", () => {
test("Changing a numeric input must not quit the editor", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
const layerRows = workspace.layers.getByTestId("layer-row");
// Draw an empty text box and, without typing anything, change the font size.
await workspace.createTextShape(200, 150, 320, 210);
await expect(layerRows).toHaveCount(1);
await workspace.textEditor.changeFontSize(24);
// The shape is not deleted and the editor is still mounted.
await expect(layerRows).toHaveCount(1);
await expect(page.getByTestId("text-editor")).toBeVisible();
// The edition survives, so we can click back into the box and keep typing.
await workspace.clickAt(210, 160);
await page.keyboard.type("hello");
await workspace.textEditor.stopEditing();
await layerRows.first().click();
await workspace.waitForSelectedShapeName("hello");
});
test("Opening the font family selector must not quit the editor", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
const layerRows = workspace.layers.getByTestId("layer-row");
// Draw an empty text box and, without typing anything, open the font family
// selector
await workspace.createTextShape(200, 150, 320, 210);
await expect(layerRows).toHaveCount(1);
await workspace.rightSidebar.getByTitle("Font Family").click();
// The shape is not deleted and the editor is still mounted.
await expect(layerRows).toHaveCount(1);
await expect(page.getByTestId("text-editor")).toBeVisible();
// The edition survives, so we can click back into the box and keep typing.
await workspace.clickAt(210, 160);
await page.keyboard.type("hello");
await workspace.textEditor.stopEditing();
await layerRows.first().click();
await workspace.waitForSelectedShapeName("hello");
});
});
test("BUG 10467 - Auto-width text captures every typed character", async ({
page,
}) => {
@@ -129,6 +241,63 @@ test("BUG 10467 - Auto-width text captures every typed character", async ({
await workspace.waitForSelectedShapeName("hello world");
});
test.describe("BUG 10910 - Text is not replaced when there is a selection", () => {
// Non-ascii on purpose: selection offsets are counted in characters.
test("Typing over a selection replaces it", async ({ page }) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
await workspace.createAutoWidthTextShape(200, 150, "Añadir");
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.type("nuevo");
await workspace.textEditor.stopEditing();
await workspace.layers.getByTestId("layer-row").first().click();
await workspace.waitForSelectedShapeName("nuevo");
});
test("Typing over a selection that contains emoji replaces it", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
await workspace.createAutoWidthTextShape(200, 150, "Hola 😀");
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.type("ok");
await workspace.textEditor.stopEditing();
await workspace.layers.getByTestId("layer-row").first().click();
await workspace.waitForSelectedShapeName("ok");
});
test("Backspace deletes the selection", async ({ page }) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
await workspace.createAutoWidthTextShape(200, 150, "Añadir texto");
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("Backspace");
await page.keyboard.type("ok");
await workspace.textEditor.stopEditing();
await workspace.layers.getByTestId("layer-row").first().click();
await workspace.waitForSelectedShapeName("ok");
});
});
test("BUG 10531 - Entering the editor auto-selects the whole text", async ({
page,
}) => {
@@ -147,9 +316,94 @@ test("BUG 10531 - Entering the editor auto-selects the whole text", async ({
await workspace.copy("keyboard");
// Assert the text was copied correctly
const copiedText = await page.evaluate(() =>
navigator.clipboard.readText(),
);
const copiedText = await page.evaluate(() => navigator.clipboard.readText());
expect(copiedText).toBe("Lorem ipsum");
});
test.describe("BUG 10934 - Double-clicking a text side handle sets auto-size", () => {
// Sets up the workspace and loads a text shape whose size is larger than its text
async function setupFixedSizeText(page) {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
// Enable token inputs so they use the new component with accessible DOM
await workspace.mockConfigFlags(["enable-feature-token-input"]);
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-fixed-size-text.json");
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
// Select the text and zoom to fit, so it is fully visible in the viewport
await workspace.clickLeafLayer("Fixed text");
await page.keyboard.press("Shift+1");
await workspace.waitForIdle();
return workspace;
}
async function doubleClickSideHandle(workspace, position) {
const handle = workspace.viewport.getByTestId(
`resize-side-handler-${position}`,
);
await handle.waitFor();
const box = await handle.boundingBox();
await workspace.page.mouse.dblclick(
box.x + box.width / 2,
box.y + box.height / 2,
);
}
function measureInput(workspace, name) {
return workspace.rightSidebar
.getByRole("region", { name: "shape-measures-section" })
.getByRole("textbox", { name, exact: true });
}
test("Double-clicking the right handle switches to auto-width", async ({
page,
}) => {
const workspace = await setupFixedSizeText(page);
const widthInput = workspace.rightSidebar
.getByRole("region", { name: "shape-measures-section" })
.getByRole("textbox", { name: "Width", exact: true });
const initialWidth = Number(await widthInput.inputValue());
await doubleClickSideHandle(workspace, "right");
// Assert auto-width is selected and that the width has shrunk. The resize
// is debounced, so poll the value (auto-retrying) rather than reading once.
await expect(
workspace.rightSidebar.getByRole("button", {
name: "Auto width",
pressed: true,
}),
).toBeVisible();
await expect
.poll(async () => Number(await widthInput.inputValue()))
.toBeLessThan(initialWidth);
});
test("Double-clicking the bottom handle switches to auto-height", async ({
page,
}) => {
const workspace = await setupFixedSizeText(page);
const heightInput = workspace.rightSidebar
.getByRole("region", { name: "shape-measures-section" })
.getByRole("textbox", { name: "Height", exact: true });
const initialHeight = Number(await heightInput.inputValue());
await doubleClickSideHandle(workspace, "bottom");
// Assert auto-height is selected and that the height has shrunk. The resize
// is debounced, so poll the value (auto-retrying) rather than reading once.
await expect(
workspace.rightSidebar.getByRole("button", {
name: "Auto height",
pressed: true,
}),
).toBeVisible();
await expect
.poll(async () => Number(await heightInput.inputValue()))
.toBeLessThan(initialHeight);
});
});
+1 -1
View File
@@ -30,7 +30,7 @@ mkdir -p target/dist;
# Build render wasm binary
pushd ../render-wasm;
./build
./build frontend
popd
pushd ../mcp;
+1 -1
View File
@@ -68,7 +68,7 @@ function slug(value) {
}
async function findGfontsJson() {
const dir = "resources/fonts";
const dir = "../common/resources/fonts";
const entries = await fs.readdir(dir);
const matches = entries.filter((f) => /^gfonts\..*\.json$/.test(f)).sort();
if (matches.length === 0) {
@@ -8,7 +8,6 @@
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.event :as ev]
[app.main.data.exports.wasm :as wasm.exports]
[app.main.data.helpers :as dsh]
@@ -183,11 +182,11 @@
(def ^:private wasm-export-types #{:jpeg :webp :png :pdf})
(defn- wasm-export-enabled?
"WASM export is available: the flag is set AND render-wasm is active for the
current file. When render-wasm is inactive its shape tree isn't loaded, so a
client-side WASM render would crash."
"WASM export is available when the `wasm-export/v1` feature is active AND
render-wasm is active for the current file. When render-wasm is inactive its
shape tree isn't loaded, so a client-side WASM render would crash."
[state]
(and (contains? cf/flags :wasm-export)
(and (features/active-feature? state "wasm-export/v1")
(features/active-feature? state "render-wasm/v1")))
(defn- use-wasm-export?
+1 -1
View File
@@ -18,6 +18,7 @@
[app.common.geom.shapes :as gsh]
[app.common.logging :as log]
[app.common.path-names :as cpn]
[app.common.render-wasm.wasm :as wasm-state]
[app.common.transit :as t]
[app.common.types.component :as ctc]
[app.common.types.components-list :as ctkl]
@@ -77,7 +78,6 @@
[app.plugins.register :as preg]
[app.render-wasm :as wasm]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm-state]
[app.util.dom :as dom]
[app.util.globals :as ug]
[app.util.http :as http]
@@ -1201,7 +1201,7 @@
;; Call exporter to get image URI, then fetch blob and resolve the deferred.
(->> (if (and (features/active-feature? state "render-wasm/v1")
(contains? cf/flags :wasm-export))
(features/active-feature? state "wasm-export/v1"))
(rx/of {:uri (wasm.exports/export-image-uri export)})
(rp/cmd! :export
{:exports [export]
@@ -11,6 +11,7 @@
[app.common.types.text :as txt]
[app.main.data.shortcuts :as ds]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.texts-v3 :as dwt-v3]
[app.main.data.workspace.undo :as dwu]
[app.main.features :as features]
[app.main.fonts :as fonts]
@@ -170,6 +171,8 @@
:else props)]
(when (and shape props)
(when (features/active-feature? @st/state "text-editor-wasm/v1")
(st/emit! (dwt-v3/v3-update-text-editor-styles (:id shape) props)))
(st/emit! (dwt/update-attrs (:id shape) props)))))
(defn blend-props
+77 -22
View File
@@ -30,6 +30,7 @@
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.texts-v3 :as dwt-v3]
[app.main.data.workspace.transforms :as dwt]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt]
@@ -699,13 +700,19 @@
(rx/concat (rx/of (dwsh/update-shapes shape-ids update-shape options))
(when (features/active-feature? state "text-editor-wasm/v1")
(let [styles ((comp update-node-fn migrate-node))
result (wasm.api/apply-styles-to-selection styles)]
;; Transform each span so add-fill preserves its existing fills.
(let [result (wasm.api/apply-styles-to-selection
(comp update-node-fn migrate-node)
{:with-fills? true})]
(when result
(rx/of (v2-update-text-shape-content
(:shape-id result)
(:content result)
:update-name? true)))))))))
:update-name? true)
;; Refresh the panel now, not only after a reselect.
(dwt-v3/v3-update-text-editor-styles
(:shape-id result)
{:fills (:fills result)})))))))))
ptk/EffectEvent
(effect [_ state _]
@@ -969,7 +976,14 @@
(watch [_ state stream]
(let [text-editor-instance (:workspace-editor state)
objects (dsh/lookup-page-objects state)
text-ids (resolve-text-ids objects id)]
text-ids (resolve-text-ids objects id)
wasm-editing?
(and (features/active-feature? state "text-editor-wasm/v1")
(= id (wasm.api/text-editor-get-active-shape-id)))
wasm-editing-selection?
(and wasm-editing? (wasm.api/text-editor-has-selection?))]
(if (and (features/active-feature? state "text-editor/v2")
(some? text-editor-instance))
(rx/empty)
@@ -979,15 +993,40 @@
(rx/of (update-root-attrs {:id id :attrs attrs}))
(rx/empty)))
(let [attrs (select-keys attrs txt/paragraph-attrs)]
(if-not (empty? attrs)
(rx/of (update-paragraph-attrs {:id id :attrs attrs}))
(rx/empty)))
;; `:line-height` is stored on both the paragraph and its spans, and
;; the renderer takes the larger of the two.
(let [pattrs (if wasm-editing-selection?
(conj txt/paragraph-attrs :line-height)
txt/paragraph-attrs)
attrs (select-keys attrs pattrs)
result (when (and (seq attrs) wasm-editing?)
(wasm.api/apply-paragraph-attrs-to-selection attrs))]
(cond
(empty? attrs)
(rx/empty)
(some? result)
(rx/of (v2-update-text-shape-content
(:shape-id result) (:content result)
:update-name? true))
:else
(rx/of (update-paragraph-attrs {:id id :attrs attrs}))))
(let [attrs (select-keys attrs txt/text-node-attrs)]
(if-not (empty? attrs)
(rx/of (update-text-attrs {:id id :attrs attrs}))
(rx/empty)))
(cond
(or (empty? attrs) wasm-editing-selection?)
(rx/empty)
;; Collapsed caret: stash a pending caret style for the next typed
;; character instead of restyling the whole shape.
wasm-editing?
(do
(wasm.text-editor/merge-pending-caret-styles! id attrs)
(rx/of (dwt-v3/v3-update-text-editor-styles id attrs)))
:else
(rx/of (update-text-attrs {:id id :attrs attrs}))))
(when (and (features/active-feature? state "text-editor/v2")
(not (features/active-feature? state "text-editor-wasm/v1")))
@@ -1209,7 +1248,7 @@
Includes :name when update-name? so we can skip save-undo on the preceding
update-shapes for finalize without losing name undo."
[it state id {:keys [new-shape? content-has-text? content original-content
update-name? name]}]
update-name? name resize-geom]}]
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
shape* (get objects id)
@@ -1221,7 +1260,8 @@
(cond-> new-shape?
(-> (pcb/set-undo-group id)
(pcb/set-stack-undo? true))))
final-geom (select-keys shape* [:selrect :points :width :height])
;; `resize-geom` is the post-resize geometry; `shape*` still holds the pre-resize selrect.
final-geom (or resize-geom (select-keys shape* [:selrect :points :width :height]))
geom-keys (if new-shape? [:selrect :points] [:selrect :points :width :height])
old-geom (when (and content-has-text? (not= :fixed (:grow-type shape*)))
(or (get-in state [:workspace-text-session-geom id])
@@ -1273,6 +1313,13 @@
;; modifier machinery, made auto-width typing very laggy.
new-size (when (and finalize? (not= :fixed (:grow-type shape)))
(dwwt/get-wasm-text-new-size shape content))
;; Also compute the resized geometry for the finalize commit; the
;; async `apply-wasm-modifiers` below never updates this `state`.
resize-modifiers (when (some? new-size)
(dwwt/resize-wasm-text-modifiers shape content))
resize-geom (when resize-modifiers
(-> (gsh/transform-shape shape (get-in resize-modifiers [id :modifiers]))
(select-keys [:selrect :points :width :height])))
;; New shapes: single undo on finalize only (no per-keystroke undo)
effective-save-undo? (if new-shape? finalize? save-undo?)
effective-stack-undo? (and new-shape? finalize?)
@@ -1282,7 +1329,16 @@
finalize-save-undo-first?
(if (and finalize? (or (not new-shape?) (not content-has-text?)))
false
effective-save-undo?)]
effective-save-undo?)
;; Whether any content-changing edit happened this editing session.
session-touched? (some? (get-in state [:workspace-text-session-geom id]))
;; A finalize on an existing shape that wasn't edited must not create any undo entry
;; (exception being newly created shapes)
finalize-no-op? (and finalize?
(not new-shape?)
content-has-text?
(not session-touched?))]
(rx/concat
(rx/of
@@ -1312,12 +1368,10 @@
:stack-undo? effective-stack-undo?
:undo-group (when new-shape? id)})
;; `new-size` is only computed on finalize (see above), so this commits
;; the final auto-width/auto-height geometry via `apply-wasm-modifiers`
;; like other transform flows (flex parents, sidebar width, etc.).
(when (some? new-size)
(when-let [modifiers (dwwt/resize-wasm-text-modifiers shape content)]
(dwm/apply-wasm-modifiers modifiers {:undo-group (when new-shape? id)}))))
;; Push the auto-grow geometry to WASM/app state; the commit persists it via `resize-geom`.
;; Skipped for a no-op finalize: applying it would record an undo transaction.
(when (and (some? resize-modifiers) (not finalize-no-op?))
(dwm/apply-wasm-modifiers resize-modifiers {:undo-group (when new-shape? id)})))
(when finalize?
(rx/concat
@@ -1334,7 +1388,7 @@
(dwsh/delete-shapes #{id})))
(rx/empty))
(rx/concat
(if content-has-text?
(if (and content-has-text? (not finalize-no-op?))
(rx/of
(dch/commit-changes
(build-finalize-commit-changes it state id
@@ -1350,7 +1404,8 @@
;; behavior (their create is bundled in the undo group).
:original-content (if new-shape? original-content prev-content)
:update-name? update-name?
:name name})))
:name name
:resize-geom resize-geom})))
(rx/empty))
(rx/of (dwt/finish-transform)
(fn [state]
+9 -61
View File
@@ -6,10 +6,10 @@
(ns app.main.fonts
"Fonts management and loading logic."
(:require-macros [app.main.fonts :refer [preload-gfonts]])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.fonts :as cfnt]
[app.common.logging :as log]
[app.common.types.text :as txt]
[app.common.uri :as u]
@@ -25,27 +25,6 @@
(log/set-level! :warn)
(def google-fonts
(preload-gfonts "fonts/gfonts.2025.11.28.json"))
(def local-fonts
[{:id "sourcesanspro"
:name "Source Sans Pro"
:family "sourcesanspro"
:variants
[{:id "200" :name "200" :weight "200" :style "normal" :suffix "extralight" :ttf-url "sourcesanspro-extralight.ttf"}
{:id "200italic" :name "200 Italic" :weight "200" :style "italic" :suffix "extralightitalic" :ttf-url "sourcesanspro-extralightitalic.ttf"}
{:id "300" :name "300" :weight "300" :style "normal" :suffix "light" :ttf-url "sourcesanspro-light.ttf"}
{:id "300italic" :name "300 Italic" :weight "300" :style "italic" :suffix "lightitalic" :ttf-url "sourcesanspro-lightitalic.ttf"}
{:id "regular" :name "400" :weight "400" :style "normal" :ttf-url "sourcesanspro-regular.ttf"}
{:id "italic" :name "400 Italic" :weight "400" :style "italic" :ttf-url "sourcesanspro-italic.ttf"}
{:id "600" :name "600" :weight "600" :style "normal" :suffix "semibold" :ttf-url "sourcesanspro-semibold.ttf"}
{:id "600italic" :name "600 Italic" :weight "600" :style "italic" :suffix "semibolditalic" :ttf-url "sourcesanspro-semibolditalic.ttf"}
{:id "bold" :name "700" :weight "700" :style "normal" :ttf-url "sourcesanspro-bold.ttf"}
{:id "bolditalic" :name "700 Italic" :weight "700" :style "italic" :ttf-url "sourcesanspro-bolditalic.ttf"}
{:id "black" :name "900" :weight "900" :style "normal" :ttf-url "sourcesanspro-black.ttf"}
{:id "blackitalic" :name "900 Italic" :weight "900" :style "italic" :ttf-url "sourcesanspro-blackitalic.ttf"}]}])
(defonce fontsdb (l/atom {}))
(defonce fonts (l/atom []))
@@ -65,10 +44,10 @@
fonts (map #(assoc % :backend backend) fonts)]
(merge db (d/index-by :id fonts))))))
(register! :builtin local-fonts)
(register! :builtin cfnt/local-fonts)
(when (contains? cf/flags :google-fonts-provider)
(register! :google google-fonts))
(register! :google cfnt/catalog))
(defn get-font-data [id]
(get @fontsdb id))
@@ -266,8 +245,7 @@
(defn- process-gfont-css
[css]
(let [base (u/join cf/public-uri "internal/gfonts/font")]
(str/replace css "https://fonts.gstatic.com/s" (dm/str base))))
(cfnt/gstatic->proxy-url css (u/join cf/public-uri "internal/gfonts/font")))
(defn- fetch-gfont-css
[url]
@@ -397,42 +375,12 @@
(defn find-closest-variant
"Find the closest font weight variant in `font` for `target-weight` with optional `target-style` match.
When exactly between two weights, choose the higher one."
When exactly between two weights, choose the higher one.
The algorithm lives in `app.common.fonts` so the headless exporter resolves the
same variant for the same text."
[font target-weight target-style]
(when-let [target-weight (d/parse-integer target-weight)]
(let [variants (:variants font [])
result
(reduce
(fn [closest-match variant]
(let [weight (d/parse-integer (:weight variant))
distance (abs (- target-weight weight))
matches-style? (= target-style (:style variant))
current {:variant variant
:weight weight
:distance distance}]
(cond
;; Exact match found
(and (zero? distance)
(if target-style matches-style? true))
(reduced current)
(nil? closest-match) current
;; Update best match if this variant is closer or equal distance but higher weight
(or (< distance (:distance closest-match))
(and (= distance (:distance closest-match))
(> weight (:weight closest-match))))
current
;; Same weight as the `closest-match` but the style matches `target-style`
(and (= weight (:weight closest-match)) matches-style?)
current
:else
closest-match)))
nil
variants)]
(:variant result))))
(cfnt/closest-variant (:variants font []) target-weight target-style))
;; Font embedding functions
(defn get-node-fonts
@@ -9,8 +9,8 @@
(:require
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.render-wasm.wasm :as wasm]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm]
[app.util.dom :as dom]
[app.util.timers :as ts]
[app.util.webapi :as webapi]
@@ -723,7 +723,7 @@
:id id
:class inner-class
:placeholder (if is-multiple?
(tr "labels.mixed-values")
(tr "settings.multiple")
placeholder)
:default-value (fmt/format-number (or (mf/ref-val last-value*) value))
:on-blur handle-blur
@@ -28,6 +28,8 @@
[app.main.ui.components.file-uploader :refer [file-uploader]]
[app.main.ui.components.radio-buttons :refer [radio-buttons radio-button]]
[app.main.ui.components.select :refer [select]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]]
[app.main.ui.hooks :as hooks]
@@ -433,10 +435,12 @@
(when (and (not= selected-mode :image)
(= color-style :direct-color))
[:button {:class (stl/css-case :picker-btn true
:selected picking-color?)
:on-click handle-click-picker}
deprecated-icon/picker])
[:> icon-button* {:icon i/picker
:variant "ghost"
:aria-label (tr "workspace.colorpicker.color-picker")
:aria-pressed picking-color?
:class (stl/css :picker-btn)
:on-click handle-click-picker}])
(when (= color-style :token-color)
[:div {:class (stl/css :token-color-title)}
@@ -467,7 +471,8 @@
[:div {:class (stl/css :select-image)}
[:div {:class (stl/css :content)}
(when (:image current-color)
[:img {:src uri}])]
[:img {:src uri
:class (stl/css :content-image)}])]
(when (some? (:image current-color))
[:div {:class (stl/css :checkbox-option)}
@@ -481,11 +486,10 @@
:id "keep-aspect-ratio"
:checked keep-aspect-ratio?
:on-change handle-change-keep-aspect-ratio}]]])
[:button
{:class (stl/css :choose-image)
:title (tr "media.choose-image")
:aria-label (tr "media.choose-image")
:on-click on-fill-image-click}
[:> button* {:class (stl/css :choose-image)
:variant "secondary"
:on-click on-fill-image-click}
(tr "media.choose-image")
[:& file-uploader
{:input-id "fill-image-upload"
@@ -554,11 +558,10 @@
:color-origin color-origin}])]
(when (fn? on-accept)
[:div {:class (stl/css :actions)}
[:button {:class (stl/css-case
:accept-color true
:btn-disabled disabled-color-accept?)
:on-click on-color-accept
:disabled disabled-color-accept?}
[:> button* {:class (stl/css :accept-color)
:variant "primary"
:on-click on-color-accept
:disabled disabled-color-accept?}
(tr "workspace.libraries.colors.save-color")]])]))
(defn calculate-position
@@ -5,11 +5,9 @@
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "ds/typography.scss" as t;
@use "ds/spacing";
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
@use "refactor/basic-rules.scss" as *;
.colorpicker-tooltip {
--colorpicker-width: #{$sz-284};
@@ -38,7 +36,7 @@
align-items: flex-start;
flex-direction: row-reverse;
justify-content: space-between;
height: $sz-40;
block-size: $sz-40;
}
.top-actions-right {
@@ -46,114 +44,14 @@
gap: var(--sp-s);
}
.opacity-input-wrapper {
@extend %input-element;
@include t.use-typography("body-small");
width: px2rem(68);
}
// TODO: change to DS button component
.picker-btn {
display: flex;
justify-content: center;
align-items: center;
background: none;
cursor: pointer;
background-color: transparent;
border: $b-1 solid transparent;
height: var(--sp-xl);
width: var(--sp-xl);
border-radius: $br-4;
padding: 0;
margin-top: var(--sp-xs);
svg {
@extend %button-icon;
stroke: var(--button-tertiary-foreground-color-rest);
}
&:hover {
svg {
stroke: var(--button-tertiary-foreground-color-focus);
}
}
&:focus,
&:focus-visible {
outline: none;
svg {
stroke: var(--button-secondary-foreground-color-hover);
}
}
&:active {
outline: none;
border: $b-1 solid transparent;
svg {
stroke: var(--button-tertiary-foreground-color-active);
}
}
&.selected {
svg {
stroke: var(--button-tertiary-foreground-color-active);
}
}
}
.gradient-buttons {
display: flex;
align-items: center;
gap: var(--sp-s);
}
.gradient-btn {
@extend %button-tertiary;
height: var(--sp-xl);
width: var(--sp-xl);
border-radius: $br-4;
border: $b-2 solid transparent;
&:hover {
border: $b-2 solid var(--colorpicker-details-color-selected);
}
}
.linear-gradient-btn {
background: linear-gradient(180deg, var(--color-foreground-secondary), transparent);
&.selected {
background: linear-gradient(to bottom, rgb(126 255 245 / 1) 0%, rgb(126 255 245 / 0.2) 100%);
border: $b-2 solid var(--colorpicker-details-color-selected);
}
}
.radial-gradient-btn {
background: radial-gradient(transparent, var(--color-foreground-secondary));
&.selected {
background: radial-gradient(rgb(126 255 245 / 1) 0%, rgb(126 255 245 / 0.2) 100%);
border: $b-2 solid var(--colorpicker-details-color-selected);
}
}
.actions {
display: flex;
gap: var(--sp-xs);
}
.accept-color {
@include t.use-typography("headline-small");
@extend %button-primary;
width: 100%;
height: var(--sp-xxxl);
margin-top: var(--sp-s);
justify-content: center;
inline-size: 100%;
}
.picker-detail-wrapper {
@@ -161,17 +59,18 @@
justify-content: center;
align-items: center;
position: relative;
margin: var(--sp-m) 0 var(--sp-s) 0;
margin-block: var(--sp-m) var(--sp-s);
margin-inline: 0;
}
.center-circle {
width: var(--sp-xxl);
height: var(--sp-xxl);
border: $b-2 solid var(--colorpicker-details-color);
border-radius: $br-circle;
position: absolute;
left: 50%;
top: 50%;
inset-inline-start: 50%;
inset-block-start: 50%;
inline-size: var(--sp-xxl);
block-size: var(--sp-xxl);
border: $b-2 solid var(--color-background-quaternary);
border-radius: $br-circle;
transform: translate(calc(-1 * var(--sp-m)), calc(-1 * var(--sp-m)));
}
@@ -181,46 +80,137 @@
}
.select {
width: px2rem(116);
inline-size: px2rem(116);
}
.select-image {
margin-top: var(--sp-xs);
margin-block-start: var(--sp-xs);
}
.content {
border-radius: $br-8;
display: flex;
justify-content: center;
border-radius: $br-8;
block-size: px2rem(140);
margin-block-end: px2rem(6);
margin-inline-end: px2rem(1);
background-image: url("/images/colorpicker-no-image.png");
background-position: center;
background-size: auto px2rem(140);
height: px2rem(140);
margin-bottom: $sz-6;
margin-right: $sz-1;
}
img {
height: fit-content;
width: fit-content;
max-height: 100%;
max-width: 100%;
margin: auto;
}
.content-image {
max-inline-size: 100%;
max-block-size: 100%;
inline-size: fit-content;
block-size: fit-content;
margin: auto;
}
.choose-image {
@extend %button-secondary;
@include t.use-typography("headline-small");
width: 100%;
margin-top: var(--sp-m);
height: var(--sp-xxxl);
justify-content: center;
inline-size: 100%;
}
// TODO: Use a DS checkbox component
.checkbox-option {
@extend %input-checkbox;
display: flex;
align-items: center;
margin-block: var(--sp-l) 0;
margin-inline: 0;
margin: var(--sp-l) 0 0 0;
// The native checkbox markup (label, span, input, svg) has no dedicated
// classes; styling these elements directly is unavoidable.
label {
@include t.use-typography("body-small");
display: flex;
align-items: center;
gap: px2rem(6);
cursor: pointer;
color: var(--color-foreground-secondary);
span {
--checkbox-icon-background-color: var(--color-background-quaternary);
--checkbox-icon-border-color: var(--color-foreground-secondary);
--checkbox-icon-foreground-color: var(--color-background-primary);
display: flex;
justify-content: center;
align-items: center;
inline-size: $sz-16;
block-size: $sz-16;
min-inline-size: $sz-16;
min-block-size: $sz-16;
border-radius: $br-4;
background-color: var(--checkbox-icon-background-color);
border: $b-1 solid var(--checkbox-icon-border-color);
svg {
display: none;
inline-size: $sz-16;
block-size: $sz-16;
stroke: var(--checkbox-icon-foreground-color);
}
&:hover {
--checkbox-icon-border-color: var(--color-accent-primary-muted);
}
&:focus {
--checkbox-icon-border-color: var(--color-accent-primary);
}
&:global(.checked) {
--checkbox-icon-background-color: var(--color-accent-primary);
--checkbox-icon-border-color: var(--color-background-quaternary);
svg {
display: flex;
justify-content: center;
align-items: center;
inline-size: $sz-12;
block-size: $sz-12;
stroke-width: 1.33px;
}
}
&:global(.intermediate) {
--checkbox-icon-background-color: var(--color-foreground-secondary);
--checkbox-icon-foreground-color: var(--color-background-secondary);
svg {
display: flex;
justify-content: center;
align-items: center;
inline-size: $sz-12;
block-size: $sz-12;
stroke-width: 1.33px;
}
}
&:global(.unchecked) {
--checkbox-icon-border-color: var(--color-background-quaternary);
}
}
input {
margin: 0;
}
&:hover {
span {
--checkbox-icon-border-color: var(--color-accent-primary-muted);
}
}
&:focus,
&:focus-within {
span {
--checkbox-icon-border-color: var(--color-accent-primary);
}
}
}
}
.token-color-title {
@@ -229,5 +219,5 @@
color: var(--color-foreground-secondary);
display: flex;
align-items: center;
height: var(--sp-xxxl);
block-size: var(--sp-xxxl);
}
@@ -7,7 +7,6 @@
(ns app.main.ui.workspace.shapes.text.text-edition-outline
(:require
[app.common.geom.shapes :as gsh]
[app.common.math :as mth]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.texts :as dwt]
[app.main.features :as features]
@@ -22,11 +21,13 @@
(let [selrect-transform (mf/deref refs/workspace-selrect)
[selrect transform] (dsh/get-selrect selrect-transform shape)
[sr-width sr-height]
(if (or (mth/close? (:width selrect) 0.01) (mth/close? (:height selrect) 0.01))
(let [{:keys [width height]} (wasm.api/get-text-dimensions (:id shape))]
[width height])
[(:width selrect) (:height selrect)])]
;; While editing, the committed selrect lags the text (geometry is
;; finalize-only), so measure the live WASM text for the growing axes:
;; width grows on auto-width, height on auto-width/auto-height.
grow-type (:grow-type shape)
{live-width :width live-height :height} (wasm.api/get-text-dimensions (:id shape))
sr-width (if (= grow-type :auto-width) live-width (:width selrect))
sr-height (if (= grow-type :fixed) (:height selrect) live-height)]
[:rect.main.viewport-selrect
{:x (:x selrect)
:y (:y selrect)
@@ -23,6 +23,20 @@
(def caret-blink-interval-ms 250)
;; Elements carrying this attr keep the edit alive when focus moves onto them (see `keep-editing-on-blur?`).
(def ^:private keep-editing-selector "[data-keep-editing-on-blur]")
(defn- keep-editing-on-blur?
"True when a surface `blur` must NOT exit the editor:
- Firefox triggering a blur when MacOS Character Viewer is open
- Focus switched to a data-keep-editing-on-blur region (e.g. typography options),
ancestors or descendants"
[^js event ^js surface]
(or (= (.-activeElement js/document) surface)
(when-let [related (dom/get-related-target event)]
(or (some? (.closest related keep-editing-selector))
(some? (.querySelector related keep-editing-selector))))))
(defn- sync-wasm-text-editor-content!
"Sync WASM text editor content back to the shape via the standard
commit pipeline. Called after every text-modifying input."
@@ -39,6 +53,48 @@
:name name
:finalize? finalize?)))))
;; Keys that move/reset the caret (or delete): pressing any abandons the pending
;; caret style. Plain character keys instead reach `on-input`, which consumes it.
(def ^:private caret-abandon-keys
#{"ArrowLeft" "ArrowRight" "ArrowUp" "ArrowDown"
"Home" "End" "PageUp" "PageDown"
"Enter" "Backspace" "Delete" "Escape" "Tab"})
(defn- caret-position
"Collapsed caret as {:para :offset} from the WASM selection, or nil."
[]
(when-let [{:keys [focus-para focus-offset]} (text-editor/text-editor-get-selection)]
{:para focus-para :offset focus-offset}))
(defn- typed-range
"Normalized range covering the text inserted between `before` and `after`, or nil."
[before after]
(when (and before after)
(if (or (< (:para before) (:para after))
(and (= (:para before) (:para after))
(<= (:offset before) (:offset after))))
{:start-para (:para before) :start-offset (:offset before)
:end-para (:para after) :end-offset (:offset after)}
{:start-para (:para after) :start-offset (:offset after)
:end-para (:para before) :end-offset (:offset before)})))
(defn- sync-with-pending-caret-styles!
"Commit an insertion that consumed a pending caret style: sync the new text,
then restyle the just-typed `range` into its own span. `before` is the
pre-insert caret."
[shape-id before]
(let [range (typed-range before (caret-position))]
;; Sync first so the cached content stays index-aligned with WASM.
(text-editor/text-editor-sync-content)
(if-let [{:keys [content]} (wasm.api/apply-pending-caret-styles! shape-id range)]
(let [text (txt/content->text content)
name (when (not= text "") (txt/generate-shape-name text))]
(st/emit! (dwt/v2-update-text-shape-content
shape-id content
:update-name? true
:name name)))
(sync-wasm-text-editor-content!))))
(defn- reset-input-node
"Empties the contenteditable capture surface and restores a collapsed caret
inside it.
@@ -99,6 +155,18 @@
(or (.-isComposing native)
(= 229 (.-keyCode event)))))
(defn- input-surface-class
"Class list for the contenteditable capture surface.
Mousetrap's `stopCallback` drops every keystroke whose target is
contentEditable, so without the `mousetrap` class (as in V1/V2) the text
shortcuts (Ctrl+B, Ctrl+I, …) never reach the dispatcher."
[rotation]
(dm/str "mousetrap "
(cur/get-dynamic "text" rotation)
" "
(stl/css :text-editor-container)))
(mf/defc text-editor*
"Contenteditable element positioned over the text shape to capture input events."
[{:keys [shape]}]
@@ -151,6 +219,8 @@
on-composition-start
(mf/use-fn
(fn [_event]
;; IME composition supplies its own text; drop any pending caret style.
(text-editor/clear-pending-caret-styles!)
(text-editor/text-editor-composition-start)))
on-composition-update
@@ -178,6 +248,8 @@
(mf/use-fn
(fn [^js event]
(dom/prevent-default event)
;; Pasted text keeps the surrounding style; drop any pending caret style.
(text-editor/clear-pending-caret-styles!)
(let [clipboard-data (.-clipboardData event)
text (.getData clipboard-data "text/plain")]
(when (and text (seq text))
@@ -191,7 +263,7 @@
(fn [^js event]
(when (text-editor/text-editor-has-focus?)
(dom/prevent-default event)
(when (text-editor/text-editor-get-selection)
(when (text-editor/text-editor-has-selection?)
(let [text (text-editor/text-editor-export-selection)]
(.setData (.-clipboardData event) "text/plain" text))))))
@@ -200,7 +272,7 @@
(fn [^js event]
(when (text-editor/text-editor-has-focus?)
(dom/prevent-default event)
(when (text-editor/text-editor-get-selection)
(when (text-editor/text-editor-has-selection?)
(let [text (text-editor/text-editor-export-selection)]
(.setData (.-clipboardData event) "text/plain" (or text ""))
(when (and text (seq text))
@@ -217,6 +289,10 @@
(let [key (.-key event)
ctrl? (or (.-ctrlKey event) (.-metaKey event))
shift? (.-shiftKey event)]
;; Ctrl+A adds select-all to the caret-abandon-keys set.
(when (or (contains? caret-abandon-keys key)
(and ctrl? (= (str/lower key) "a")))
(text-editor/clear-pending-caret-styles!))
(cond
;; Escape: finalize and stop
(= key "Escape")
@@ -256,6 +332,15 @@
(sync-wasm-text-editor-content!)
(wasm.api/request-render-preserving-target "text-delete-forward"))
;; Shift+Tab falls through to the browser, so the keyboard can
;; still leave the editor.
(and (= key "Tab") (not shift?))
(do
(dom/prevent-default event)
(text-editor/text-editor-insert-text "\t")
(sync-wasm-text-editor-content!)
(wasm.api/request-render-preserving-target "text-tab"))
;; Insert
(= key "Insert")
(do
@@ -344,8 +429,14 @@
(let [pending (mf/ref-val pending-replace-ref)]
(dotimes [_ pending]
(text-editor/text-editor-delete-backward)))
(text-editor/text-editor-insert-text data)
(sync-wasm-text-editor-content!)
(let [shape-id (text-editor/text-editor-get-active-shape-id)
;; The inserted character adopts a pending caret style, if any.
pending-styles? (some? (text-editor/get-pending-caret-styles shape-id))
before (when pending-styles? (caret-position))]
(text-editor/text-editor-insert-text data)
(if pending-styles?
(sync-with-pending-caret-styles! shape-id before)
(sync-wasm-text-editor-content!)))
(wasm.api/request-render-preserving-target "text-input"))
(mf/set-ref-val! pending-replace-ref 0)
;; IMPORTANT: do NOT clear the surface here (see keep-input-alive):
@@ -358,8 +449,13 @@
(fn [^js event]
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)]
;; Repositioning the caret abandons the pending caret style (also
;; covers click and double-click, which fire pointer-down first).
(text-editor/clear-pending-caret-styles!)
(mf/set-ref-val! dragging-ref true)
(wasm.api/text-editor-pointer-down off-pt)
(if (.-shiftKey event)
(wasm.api/text-editor-pointer-down-extend off-pt)
(wasm.api/text-editor-pointer-down off-pt))
;; Repaint the caret over the cached tiles instead of a full render,
;; which flashes at high zoom (see `render-text-editor-overlay!`).
(wasm.api/render-text-editor-overlay!))))
@@ -407,9 +503,13 @@
on-blur
(mf/use-fn
(fn [^js _event]
(sync-wasm-text-editor-content! {:finalize? true})
(wasm.api/text-editor-blur)))
(fn [^js event]
;; A blur exits the editor unless keep-editing-on-blur? is true
(when-not (and (some? event)
(keep-editing-on-blur? event (mf/ref-val contenteditable-ref)))
(text-editor/clear-pending-caret-styles!)
(sync-wasm-text-editor-content! {:finalize? true})
(wasm.api/text-editor-blur))))
style #js {:pointerEvents "all"
"--editor-container-width" (dm/str width "px")
@@ -505,7 +605,5 @@
:on-focus on-focus
:on-blur on-blur
:id "text-editor-wasm-input"
:class (dm/str (cur/get-dynamic "text" (:rotation shape))
" "
(stl/css :text-editor-container))
:class (input-surface-class (:rotation shape))
:data-testid "text-editor-container"}]]]]))
@@ -296,7 +296,7 @@
(if mixed-state
[:div {:class (stl/css :first-row)}
[:span {:class (stl/css :mixed-label)}
(tr "labels.mixed-values")]
(tr "settings.multiple")]
[:> icon-button* {:variant "ghost"
:aria-label (tr "workspace.options.blur-options.remove-blur")
:on-click handle-delete-all
@@ -498,6 +498,8 @@
(ts/schedule 0 #(some-> (mf/ref-val dropdown-ref) dom/focus!))))
[:section {:class (stl/css :element-set)
;; Focusing these controls must not exit the v3 text editor (see `keep-editing-on-blur?`).
:data-keep-editing-on-blur true
:aria-label (tr "workspace.options.text-options.text-section")}
[:div {:class (stl/css :element-title)}
[:> title-bar* {:collapsable true
@@ -18,6 +18,8 @@
[app.main.data.helpers :as dsh]
[app.main.data.workspace :as dw]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.context :as ctx]
@@ -26,6 +28,7 @@
[app.util.debug :as dbg]
[app.util.dom :as dom]
[app.util.object :as obj]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
(def rotation-handler-size 20)
@@ -295,13 +298,20 @@
on-double-click
(mf/use-fn
(mf/deps shape-id position shape-type)
(fn [_event]
(fn [event]
(when (= shape-type :text)
(cond
(= position :right)
(st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type :auto-width)))
(= position :bottom)
(st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type :auto-height)))))))]
;; Prevent the viewport double-click handler from entering text editor
(dom/stop-propagation event)
(let [grow-type (case position
:right :auto-width
:bottom :auto-height
nil)]
(when (some? grow-type)
(st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type grow-type)))
;; The WASM renderer needs an explicit reflow after the grow-type change
(when (features/active-feature? @st/state "render-wasm/v1")
(st/emit! (dwwt/resize-wasm-text-all [shape-id])
(ptk/data-event :layout/update {:ids [shape-id]}))))))))]
[:g.resize-handler
(when ^boolean show-handler
@@ -321,6 +331,7 @@
:height height
:class cursor
:data-position (name position)
:data-testid (dm/str "resize-side-handler-" (name position))
:transform transform-str
:on-pointer-down on-resize
:on-double-click on-double-click
+2 -2
View File
@@ -33,7 +33,6 @@
[app.common.types.shape.shadow :as ctss]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.exports.assets :as de]
[app.main.data.exports.wasm :as wasm.exports]
[app.main.data.persistence :as dwp]
@@ -50,6 +49,7 @@
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.variants :as dwv]
[app.main.features :as features]
[app.main.repo :as rp]
[app.main.store :as st]
[app.plugins.exports :as exports]
@@ -1532,7 +1532,7 @@
(u/not-valid plugin-id :export value)
:else
(if (and (contains? cf/flags :wasm-export)
(if (and (features/active-feature? @st/state "wasm-export/v1")
(contains? #{:jpeg :webp :png} (:type value :png)))
;; New export with wasm
(let [uri (wasm.exports/export-image-uri
+61 -32
View File
@@ -13,8 +13,17 @@
[app.common.exceptions :as ex]
[app.common.files.focus :as cpf]
[app.common.files.helpers :as cfh]
[app.common.fonts :as cfnt]
[app.common.logging :as log]
[app.common.math :as mth]
[app.common.render-wasm.api.props :as props]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.mem.heap32 :as mem.h32]
[app.common.render-wasm.serialize-shape :as serialize-shape]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.serializers.color :as sr-clr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.color :as clr]
[app.common.types.fills :as types.fills]
[app.common.types.path :as path]
@@ -31,23 +40,17 @@
[app.main.router :as rt]
[app.main.store :as st]
[app.main.ui.shapes.text]
;; Required for side effects: binds the generated enums.
[app.render-wasm.api.enums]
[app.render-wasm.api.fonts :as f]
[app.render-wasm.api.props :as props]
[app.render-wasm.api.texts :as t]
[app.render-wasm.api.webgl :as webgl]
[app.render-wasm.deserializers :as dr]
[app.render-wasm.gesture :as wasm-gesture]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.mem.heap32 :as mem.h32]
[app.render-wasm.performance :as perf]
[app.render-wasm.rulers-state :as rulers-state]
[app.render-wasm.serialize-shape :as serialize-shape]
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.svg-filters :as svg-filters]
[app.render-wasm.text-editor :as text-editor]
[app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg]
[app.util.dom :as dom]
[app.util.functions :as fns]
@@ -64,6 +67,15 @@
(def use-dpr? (contains? cf/flags :render-wasm-dpr))
(defn- wasm-get-numeric-value
"Read a positive numeric query param (e.g. `?dpr=2`)."
[name]
(when-let [raw (let [p (rt/get-params @st/state)]
(get p name))]
(let [n (if (string? raw) (js/parseFloat raw) raw)]
(when (and (number? n) (not (js/isNaN n)) (pos? n))
n))))
;; --- Page transition state (WASM viewport)
;;
;; Goal: avoid showing tile-by-tile rendering during page switches (and initial load),
@@ -281,6 +293,7 @@
(def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point)
(def text-editor-toggle-overtype-mode text-editor/text-editor-toggle-overtype-mode)
(def text-editor-pointer-down text-editor/text-editor-pointer-down)
(def text-editor-pointer-down-extend text-editor/text-editor-pointer-down-extend)
(def text-editor-pointer-move text-editor/text-editor-pointer-move)
(def text-editor-pointer-up text-editor/text-editor-pointer-up)
(def text-editor-get-current-styles text-editor/text-editor-get-current-styles)
@@ -296,14 +309,18 @@
(defn get-dpr
"Returns the current device pixel ratio. Use instead of `dpr` wherever
the value must reflect browser-zoom changes that happen after load."
the value must reflect browser-zoom changes that happen after load.
Override with query param `?dpr=2` (or any positive number) for HiDPI repro
without relying on the real `devicePixelRatio`."
[]
(if use-dpr?
(let [d (.-devicePixelRatio ^js ug/window)]
;; In workers `ug/window` is a mock without `devicePixelRatio`,
;; so guard against nil/NaN/non-positive values.
(if (and (number? d) (pos? d)) d 1.0))
1.0))
(or (wasm-get-numeric-value :dpr)
(if use-dpr?
(let [d (.-devicePixelRatio ^js ug/window)]
;; In workers `ug/window` is a mock without `devicePixelRatio`,
;; so guard against nil/NaN/non-positive values.
(if (and (number? d) (pos? d)) d 1.0))
1.0)))
(def noop-fn
(constantly nil))
@@ -703,12 +720,32 @@
(defn apply-styles-to-selection
"Apply style attrs to the currently selected text spans.
Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving."
[attrs]
(let [result (text-editor/apply-styles-to-selection attrs use-shape set-shape-text-content)]
Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving.
`:with-fills?` also returns the selection's `:fills`."
[styles & [opts]]
(let [result (text-editor/apply-styles-to-selection styles use-shape set-shape-text-content opts)]
(request-render "apply-styles-to-selection")
result))
(defn apply-paragraph-attrs-to-selection
"Apply paragraph attrs to the paragraphs the editor selection touches.
Returns {:shape-id :content} for saving."
[attrs]
(let [result (text-editor/apply-paragraph-attrs-to-selection attrs use-shape set-shape-text-content)]
(request-render "apply-paragraph-attrs-to-selection")
result))
(defn apply-pending-caret-styles!
"Apply the shape's pending caret style over `range` (the just-typed text) and
clear it; returns {:shape-id :content} or nil when there is none."
[shape-id range]
(when-let [styles (text-editor/get-pending-caret-styles shape-id)]
(let [result (text-editor/apply-styles-to-range
shape-id range styles use-shape set-shape-text-content)]
(text-editor/clear-pending-caret-styles!)
(request-render "apply-pending-caret-styles")
result)))
(defn set-parent-id
[id]
(let [buffer (uuid/get-u32 id)]
@@ -1285,8 +1322,8 @@
langs)
(let [text (apply str (map :text spans))
emoji? (if emoji? emoji? (t/contains-emoji? text))
langs (t/collect-used-languages langs text)]
emoji? (if emoji? emoji? (cfnt/contains-emoji? text))
langs (cfnt/collect-used-languages langs text)]
;; FIXME: this should probably be somewhere else
(when fallback-fonts-only? (t/write-shape-text spans paragraph text))
@@ -1297,8 +1334,8 @@
(let [updated-fonts
(-> #{}
(cond-> ^boolean emoji? (f/add-emoji-font))
(f/add-noto-fonts langs))
(cond-> ^boolean emoji? (cfnt/add-emoji-font))
(cfnt/add-noto-fonts langs))
fallback-fonts (filter #(get % :is-fallback) updated-fonts)]
(if fallback-fonts-only? updated-fonts fallback-fonts))))))
@@ -1404,7 +1441,7 @@
;; this implicitly (`zoom_changed`); this extends it to pan/resize-triggered
;; ends (e.g. selecting a shape opens the options panel and resizes the
;; viewport), which previously blanked.
(internal-render 0 RENDER-FLAG-SYNC-TILES)
(internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES)
;; The direct render above bypasses the rAF `render` loop, so repaint the
;; editor overlay explicitly. Only when this was a full frame: a progressive
;; render keeps painting through the rAF loop and its partial frames must not
@@ -1419,7 +1456,7 @@
(if (view-gesture-active?)
;; Pan/zoom pause: render without ending the interaction.
(do
(internal-render 0 RENDER-FLAG-SYNC-TILES)
(internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES)
(render-text-editor-overlay-after-frame!))
(finalize-view-interaction!))))]
(fns/debounce do-render DEBOUNCE_DELAY_MS)))
@@ -2152,14 +2189,6 @@
(set-render-options! dpr)
(resize-viewbox (/ new-physical-w dpr) (/ new-physical-h dpr)))))
(defn- wasm-get-numeric-value
[name]
(when-let [raw (let [p (rt/get-params @st/state)]
(get p name))]
(let [n (if (string? raw) (js/parseFloat raw) raw)]
(when (and (number? n) (not (js/isNaN n)) (pos? n))
n))))
(defn- wasm-set-param-from-route-params-if-present
[param-name]
(when-let [value (wasm-get-numeric-value param-name)]
@@ -0,0 +1,19 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.api.enums
"Binds this build's generated enums into the shared bridge.
`shared.js` is emitted next to this file by `render-wasm/build frontend` and
is not committed. Requiring this namespace is what makes
`app.common.render-wasm.wasm/serializers` usable."
(:require
["./shared.js" :as shared]
[app.common.render-wasm.wasm :as wasm])
(:require-macros
[app.common.render-wasm.enums :as enums]))
(wasm/init-serializers! (enums/serializers shared))
+9 -62
View File
@@ -8,15 +8,15 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.fonts :as cfnt]
[app.common.logging :as log]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.fonts :as fonts]
[app.main.store :as st]
[app.render-wasm.fallback-fonts :as fbf]
[app.render-wasm.helpers :as h]
[app.render-wasm.wasm :as wasm]
[app.util.http :as http]
[app.util.timers :as tm]
[beicon.v2.core :as rx]
@@ -39,33 +39,6 @@
(def ^:private default-line-height 1.2)
(def ^:private default-letter-spacing 0.0)
(defn- google-font-id->uuid
"Returns the UUID for a Google Font ID. Uses uuid/zero as fallback when the
font is not found in fontsdb. uuid/zero maps to the default font (Source
Sans Pro) in WASM.
A font id may not exist for different reasons:
- the gfonts.json catalog was updated and fonts were renamed or removed,
- the file was imported from another Penpot instance with different fonts,
..."
[font-id]
(let [font (fonts/get-font-data font-id)
result (:uuid font)]
(or result uuid/zero)))
(defn- custom-font-id->uuid
[font-id]
(uuid/uuid (subs font-id (inc (str/index-of font-id "-")))))
(defn- font-backend
[font-id]
(cond
(str/starts-with? font-id "gfont-")
:google
(str/starts-with? font-id "custom-")
:custom
:else
:builtin))
(defn- font-db-data
[font-id font-variant-id font-weight-fallback font-style-fallback]
(let [font (fonts/get-font-data font-id)
@@ -75,15 +48,6 @@
variant
closest-variant)))
(defn- font-id->uuid [font-id]
(case (font-backend font-id)
:google
(google-font-id->uuid font-id)
:custom
(custom-font-id->uuid font-id)
:builtin
uuid/zero))
(defn uuid->font-id
[font-uuid]
(if (= font-uuid uuid/zero)
@@ -100,11 +64,11 @@
"regular")))
(defn ^:private font-id->asset-id [font-id font-variant-id font-weight font-style]
(case (font-backend font-id)
(case (cfnt/font-id->backend font-id)
:google
font-id
:custom
(let [font-uuid (custom-font-id->uuid font-id)
(let [font-uuid (cfnt/font-id->uuid font-id)
matching-font (some (fn [[_ font]]
(and (= (:font-id font) font-uuid)
(= (str (:font-weight font)) (str font-weight))
@@ -194,13 +158,12 @@
(defn- google-font-ttf-url
[font-id font-variant-id font-weight font-style]
(let [variant (font-db-data font-id font-variant-id font-weight font-style)]
(if-let [ttf-url (:ttf-url variant)]
(str/replace ttf-url "https://fonts.gstatic.com/s/" (u/join cf/public-uri "internal/gfonts/font/"))
nil)))
(when-let [ttf-url (:ttf-url variant)]
(cfnt/gstatic->proxy-url ttf-url (u/join cf/public-uri "internal/gfonts/font")))))
(defn- font-id->ttf-url
[font-id asset-id font-variant-id font-weight font-style]
(case (font-backend font-id)
(case (cfnt/font-id->backend font-id)
:google
(google-font-ttf-url font-id font-variant-id font-weight font-style)
:custom
@@ -245,18 +208,6 @@
"italic" 1
0))
(defn normalize-font-id
[font-id]
(try
(if ^boolean (str/starts-with? font-id "gfont-")
(google-font-id->uuid font-id)
(let [no-prefix (subs font-id (inc (str/index-of font-id "-")))]
(if (or (nil? no-prefix) (not (string? no-prefix)) (str/blank? no-prefix))
uuid/zero
(uuid/parse no-prefix))))
(catch :default _e
uuid/zero)))
(defn normalize-span-font
[span paragraph]
(let [font-id (:font-id span)
@@ -358,7 +309,7 @@
emoji? (get font :is-emoji false)
fallback? (get font :is-fallback false)
font-data (font-db-data font-id normalized-variant-id font-weight-fallback font-style-fallback)
wasm-id (font-id->uuid font-id)
wasm-id (cfnt/font-id->uuid font-id)
raw-weight (or (:weight font-data) font-weight-fallback)
weight (serialize-font-weight raw-weight)
style (cond
@@ -415,7 +366,3 @@
(defn store-fonts
[fonts]
(keep (fn [font] (store-font font)) fonts))
(def add-emoji-font fbf/add-emoji-font)
(def noto-fonts fbf/noto-fonts)
(def add-noto-fonts fbf/add-noto-fonts)
+4 -12
View File
@@ -6,21 +6,13 @@
(ns app.render-wasm.api.texts
(:require
[app.render-wasm.api.fonts :as f]
[app.render-wasm.fallback-fonts :as fbf]
[app.render-wasm.text-content :as tc]))
[app.common.render-wasm.text-content :as tc]
[app.render-wasm.api.fonts :as f]))
(defn write-shape-text
"Workspace text serialization: the byte writing is shared via
`app.render-wasm.text-content`; font resolution is the workspace's (fonts DB)."
`app.common.render-wasm.text-content`; font resolution is the workspace's (fonts DB)."
[spans paragraph text]
(tc/write-shape-text! spans paragraph text
{:normalize-font-id f/normalize-font-id
:normalize-paragraph f/normalize-paragraph-font
{:normalize-paragraph f/normalize-paragraph-font
:normalize-span f/normalize-span-font}))
;; Emoji/script detection lives in the host-agnostic
;; `app.render-wasm.fallback-fonts`; kept re-exported here for existing
;; workspace callers.
(def contains-emoji? fbf/contains-emoji?)
(def collect-used-languages fbf/collect-used-languages)
+1 -1
View File
@@ -8,7 +8,7 @@
"WebGL utilities for pixel capture and rendering"
(:require
[app.common.logging :as log]
[app.render-wasm.wasm :as wasm]
[app.common.render-wasm.wasm :as wasm]
[promesa.core :as p]))
(defn get-webgl-context
+162 -54
View File
@@ -7,16 +7,18 @@
(ns app.render-wasm.text-editor
"Text editor WASM bindings"
(:require
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.serializers.color :as sr-clr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.fills.impl :as types.fills.impl]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.main.fonts :as main-fonts]
;; Required for side effects: binds the generated enums.
[app.render-wasm.api.enums]
[app.render-wasm.api.fonts :as fonts]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.wasm :as wasm]
[app.util.color :as uc]
[app.util.dom :as dom]))
@@ -222,6 +224,12 @@
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_pointer_down" x y)))
(defn text-editor-pointer-down-extend
"Extends the selection up to the pointer instead of collapsing the caret."
[{:keys [x y]}]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_pointer_down_extend" x y)))
(defn text-editor-pointer-move
[{:keys [x y]}]
(when (wasm/ready?)
@@ -539,6 +547,24 @@
[shape-id content]
(swap! shape-text-contents assoc shape-id content))
;; Typography chosen at a collapsed caret: not applied to existing text, but
;; picked up (as a new span) by the next inserted text. Keyed by shape-id.
(def ^:private pending-caret-styles (atom {}))
(defn merge-pending-caret-styles!
"Stack `styles` onto the shape's pending caret style."
[shape-id styles]
(swap! pending-caret-styles update shape-id merge styles))
(defn get-pending-caret-styles
[shape-id]
(get @pending-caret-styles shape-id))
(defn clear-pending-caret-styles!
"Drop every pending caret style; only the active shape can hold one."
[]
(reset! pending-caret-styles {}))
(defn- merge-exported-texts-into-content
"Merge exported span texts back into the existing content tree.
@@ -628,10 +654,9 @@
{:start-para focus-para :start-offset focus-offset
:end-para anchor-para :end-offset anchor-offset}))
(defn- apply-attrs-to-paragraph
"Apply attrs to spans within [sel-start, sel-end) char range of a single paragraph.
Splits spans at boundaries as needed."
[para sel-start sel-end attrs]
(defn apply-attrs-to-paragraph
"Apply `styles` (attrs map, or a fn per span) within [sel-start, sel-end), splitting spans."
[para sel-start sel-end styles]
(let [spans (:children para)
result (loop [spans spans
@@ -650,8 +675,10 @@
(recur (rest spans) span-end (conj acc span))
(let [before (when (> ol-start pos)
(assoc span :text (subs text 0 (- ol-start pos))))
selected (merge span attrs
{:text (subs text (- ol-start pos) (- ol-end pos))})
selected (-> (if (fn? styles)
(styles span)
(merge span styles))
(assoc :text (subs text (- ol-start pos) (- ol-end pos))))
after (when (< ol-end span-end)
(assoc span :text (subs text (- ol-end pos))))]
(recur (rest spans) span-end
@@ -663,15 +690,81 @@
[para]
(apply + (map (fn [span] (count (:text span))) (:children para))))
(defn- paragraph-selected-spans
"Return the spans of `para` that overlap the [sel-start, sel-end) char range."
[para sel-start sel-end]
(loop [spans (:children para)
pos 0
acc []]
(if (empty? spans)
acc
(let [span (first spans)
span-end (+ pos (count (:text span)))
overlap? (< (max pos sel-start) (min span-end sel-end))]
(recur (rest spans) span-end (cond-> acc overlap? (conj span)))))))
(defn selection-fills
"The selection's fills: shared vector if all spans match, `:multiple` if not, nil if empty."
[content {:keys [start-para start-offset end-para end-offset]}]
(let [paragraphs (:children (first (:children content)))
selected (mapcat (fn [idx para]
(cond
(or (< idx start-para) (> idx end-para)) nil
(= start-para end-para) (paragraph-selected-spans para start-offset end-offset)
(= idx start-para) (paragraph-selected-spans para start-offset (para-char-count para))
(= idx end-para) (paragraph-selected-spans para 0 end-offset)
:else (paragraph-selected-spans para 0 (para-char-count para))))
(range (count paragraphs))
paragraphs)
fills-set (into #{} (map :fills) selected)]
(cond
(empty? selected) nil
(= 1 (count fills-set)) (first fills-set)
:else :multiple)))
(defn- apply-styles-over-range
"Apply `styles` (attrs map or per-span fn) to the char range of `content`, splitting spans."
[content {:keys [start-para start-offset end-para end-offset]} styles]
(let [paragraph-set (first (:children content))
paragraphs (:children paragraph-set)
new-paragraphs (mapv (fn [idx para]
(cond
;; paragraph outside the range of paragraphs.
(or (< idx start-para) (> idx end-para))
para
;; same paragraph.
(= start-para end-para)
(apply-attrs-to-paragraph para start-offset end-offset styles)
;; first paragraph
(= idx start-para)
(apply-attrs-to-paragraph para start-offset (para-char-count para) styles)
;; final paragraph
(= idx end-para)
(apply-attrs-to-paragraph para 0 end-offset styles)
;; any other paragraph
:else
(apply-attrs-to-paragraph para 0 (para-char-count para) styles)))
(range (count paragraphs))
paragraphs)]
(assoc content :children [(assoc paragraph-set :children new-paragraphs)])))
(defn- clean-styles
"Drop nil-valued attrs (unlike the DOM path, our merge would keep them and fail
the backend schema); a per-span fn is passed through untouched."
[styles]
(if (fn? styles)
styles
(into {} (remove (comp nil? val)) styles)))
(defn apply-styles-to-selection
[attrs use-shape-fn set-shape-text-content-fn]
"Apply `styles` (attrs map, or a fn per span) to the selected spans; `:with-fills?` also returns `:fills`."
[styles use-shape-fn set-shape-text-content-fn & [{:keys [with-fills?]}]]
(when (wasm/ready?)
(let [;; Drop nil-valued attrs so they are never merged onto text spans.
;; The DOM editor path strips these in `attrs->styles`; the WASM merge
;; here (`apply-attrs-to-paragraph`) does not, so an unresolved attr
;; (e.g. nil :font-family/:font-weight/:font-style from an unloaded
;; font) would corrupt the span and fail the backend schema.
attrs (into {} (remove (comp nil? val)) attrs)
(let [styles (clean-styles styles)
shape-id (text-editor-get-active-shape-id)
selection (text-editor-get-selection)]
@@ -681,45 +774,60 @@
(let [normalized-selection (normalize-selection selection)
{:keys [start-para start-offset end-para end-offset]} normalized-selection
collapsed? (and (= start-para end-para) (= start-offset end-offset))
collapsed? (and (= start-para end-para) (= start-offset end-offset))
paragraph-set (first (:children content))
paragraphs (:children paragraph-set)
new-paragraphs
(when (not collapsed?)
(mapv (fn [idx para]
(cond
;; paragraph outside the range of paragraphs.
(or (< idx start-para) (> idx end-para))
para
;; same paragraph.
(= start-para end-para)
(apply-attrs-to-paragraph para start-offset end-offset attrs)
;; first paragraph
(= idx start-para)
(apply-attrs-to-paragraph para start-offset (para-char-count para) attrs)
;; final paragraph
(= idx end-para)
(apply-attrs-to-paragraph para 0 end-offset attrs)
;; any other paragraph
:else
(apply-attrs-to-paragraph para 0 (para-char-count para) attrs)))
(range (count paragraphs))
paragraphs))
new-content (when new-paragraphs
(assoc content :children
[(assoc paragraph-set :children new-paragraphs)]))]
new-content (when (not collapsed?)
(apply-styles-over-range content normalized-selection styles))]
(when new-content
(update-cached-content! shape-id new-content)
(use-shape-fn shape-id)
(set-shape-text-content-fn shape-id new-content)
{:shape-id shape-id
:content new-content}))))))))
(cond-> {:shape-id shape-id
:content new-content}
with-fills?
(assoc :fills (selection-fills new-content normalized-selection)))))))))))
(defn apply-styles-to-range
"Like `apply-styles-to-selection` but over an explicit range (used to restyle
just-inserted text); returns `{:shape-id :content}` or nil."
[shape-id {:keys [start-para start-offset end-para end-offset] :as range} styles
use-shape-fn set-shape-text-content-fn]
(when (wasm/ready?)
(let [styles (clean-styles styles)
content (get-cached-content shape-id)]
(when (and content
(seq styles)
(not (and (= start-para end-para) (= start-offset end-offset))))
(let [new-content (apply-styles-over-range content range styles)]
(update-cached-content! shape-id new-content)
(use-shape-fn shape-id)
(set-shape-text-content-fn shape-id new-content)
{:shape-id shape-id
:content new-content})))))
(defn apply-paragraph-attrs-to-selection
"Apply paragraph level attrs (text-align, text-direction) to the whole
paragraphs the editor selection touches; a collapsed caret means just the one
it sits in."
[attrs use-shape-fn set-shape-text-content-fn]
(when (wasm/ready?)
(let [shape-id (text-editor-get-active-shape-id)
selection (text-editor-get-selection)]
(when (and shape-id selection)
(when-let [content (get-cached-content shape-id)]
(let [{:keys [start-para end-para]} (normalize-selection selection)
paragraph-set (first (:children content))
new-paragraphs (into []
(map-indexed (fn [idx para]
(if (<= start-para idx end-para)
(merge para attrs)
para)))
(:children paragraph-set))
new-content (assoc content :children
[(assoc paragraph-set :children new-paragraphs)])]
(update-cached-content! shape-id new-content)
(use-shape-fn shape-id)
(set-shape-text-content-fn shape-id new-content)
{:shape-id shape-id
:content new-content}))))))
+11 -1
View File
@@ -46,8 +46,18 @@
[]
(dom/query "[data-itype=\"editor\"]"))
(defn v3-get-text-editor-content
[]
(dom/get-element "text-editor-wasm-input"))
(defn get-text-editor-content
[]
(if (features/active-feature? @st/state "text-editor/v2")
(cond
(features/active-feature? @st/state "text-editor-wasm/v1")
(v3-get-text-editor-content)
(features/active-feature? @st/state "text-editor/v2")
(v2-get-text-editor-content)
:else
(v1-get-text-editor-content)))
+1 -1
View File
@@ -11,6 +11,7 @@
[app.common.geom.rect :as grc]
[app.common.geom.shapes.bounds :as gsb]
[app.common.logging :as log]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.color :as cc]
[app.common.uri :as u]
[app.common.uuid :as uuid]
@@ -18,7 +19,6 @@
[app.main.fonts :as fonts]
[app.main.render :as render]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm]
[app.util.http :as http]
[app.worker.impl :as impl]
[beicon.v2.core :as rx]
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more