Repository navigation
Canvas Profiles
Which canvas profile a page renders with, how the answer is recorded, and how an edit to a profile is noticed later. This is the most intricate rule in the library, and it grew large enough inside the pipeline chapter to be lifted out of it.
The short version: a page is matched by its exact display size (EXIF-upright W×H). A page that matches nothing is still rendered — implicitly, without margins — and flagged, because determinism is preserved by visibility rather than by dropping work.
Goal: maximum determinism. The workspace may hold many profiles, but an image must resolve to exactly one profile (or an unambiguous error) before processing begins. The algorithm is deterministic given the same project state — it never makes silent guesses.
Pre-run (once per project), inside CanvasProfileMatcher constructor:
Partition workspace.canvasProfiles() into:
subA = profiles whose id ∈ project.canvasProfileIds (preserves priority order)
subB = all remaining workspace profiles (fallback pool)
The conflict guard (§7.5.2) guarantees subA contains at most one profile per W×H pair, so the first match in subA is always final — no tie-breaking needed.
Per image (called for each PageItem in the project):
1. Read image width W from the file header (no full pixel decode).
2. Read image height H from the file header.
3. Search subA for first cp where cp.canvasSize.width == W
and cp.canvasSize.height == H
→ if found: Matched — use cp (margin-aware pipeline). Done.
4. Search subB for first cp where cp.canvasSize.width == W
and cp.canvasSize.height == H
4a. If not found in subB either:
→ NotFoundAnywhere
→ page is **rendered implicitly**: scaled to the output profile's
`targetWidth` with no margin crop (the same path a project with no
canvas profiles uses). `appliedProfiles` records an empty profile id.
→ reported live via `onInput` as `AppendedWithoutProfile` and logged
(Info): "No canvas profile matches <W>×<H> — rendering <file> without margins".
4b. If found in subB:
→ FoundInWorkspaceOnly
→ page is **rendered implicitly** exactly as in 4a (no margins), but loudly:
→ reported live via `onInput` as `AppendedProfileNotLinked` carrying the
matching workspace-profile ids, and logged (Warning):
"No linked canvas profile matches <W>×<H> — rendering <file> without
margins; profile '<name>' (<W>×<H>) exists in the workspace but is not
linked to this project. Link it to apply its margins."
→ linking the profile (`platemaker project add-profile --project <NAME>
--profile <ID>`) is a one-click fix that applies its margins on the next run.
Design rationale: Neither 4a nor 4b drops the page. Dropping is silent — because slice numbering is continuous, a missing page leaves no visible gap in the output, so a published chapter can lose a page unnoticed. Rendering the page implicitly and flagging the input preserves determinism by visibility rather than by omission: adding a workspace profile still cannot silently change a project's margins (4b renders without them and says so loudly until the user consciously links it), but a page is never lost. This covers quick-start (render right after install, before any profile exists) and late-added, profile-less frames. The
SkippedNoProfile/SkippedProfileNotLinkedInputStatusvalues are retained (currently unemitted) so a future opt-in "drop unmatched pages" mode can restore the strict behaviour without a breaking change.
Model effect. An implicitly-rendered page is a normal rendered input: applyProcessingResults()
marks it FileStatus::Processed with an empty canvasProfileId — the empty id being the honest
record that no profile was applied (and the reason a later link of a matching profile registers as a
staleness change). FileStatus::Skipped now covers only a missing file or a load error — the
run's skippedPages (ProcessingOutcome::skippedPages) lists exactly those, and
ProjectEditor::applyProcessingResults() marks them, so a genuinely-skipped page is flagged instead of
masquerading as done. The pipeline reports each input live in phase 1 through
ProcessingCallbacks::onInput(InputResult) — Appended (matched a profile, or no profiles in use),
AppendedWithoutProfile / AppendedProfileNotLinked (rendered implicitly, the latter carrying the
unlinked candidate ids), or SkippedMissing / SkippedError — which the GUI uses to colour input
tiles as the strip is built.
The virtual strip is a continuous concatenation built in InputFile::order sequence (via
ProjectItem::inputsInOrder()), independent of the stored-vector order. Reordering an input — or adding
or removing one — shifts pixels across every downstream slice while leaving each input and output
file byte-identical, so neither the SHA-256 pass nor the canvas/output-signature axes can see it.
ProjectEditor::applyProcessingResults() therefore records inputOrderAtRender (the input uid sequence,
in order, at render — the sibling of canvasProfileIdsAtRender, keyed by uid so a rename does not
false-invalidate). sanitize() compares it via detectInputCompositionChange() and, on a mismatch,
marks every output Desynchronized (the whole strip moved); callers fold the same signal into their
"config changed" decision so the full render path runs and refreshes the baseline. A project rendered
before this field existed has its baseline backfilled by WorkspaceSerializer::load() from the
outputs' sourceMap provenance, so a pre-existing reorder is caught without a spurious re-render.
Reordering goes through Infrastructure::ProjectEditor (the project-content twin of WorkspaceEditor),
which rewrites only the order field — a reorder never physically moves m_input_images.
Editing a canvas profile — or adding, removing, or reordering one — changes neither the input files
nor the output files, so no SHA-256 pass can notice that a page's output went stale.
ProjectItem::detectCanvasConfigChange() is what notices, and it must be precise: marking a whole
project stale whenever the profile list merely grows turns every tile amber and raises an alarming prompt
even when the new profile matches no page in the project.
Because matching is purely by canvas W×H (§7.5.1), the question "which profile would this page match
now?" is answerable offline only if the page's dimensions are known. Each InputFile therefore records
the display width/height the render resolved against — the same post-autorot dimensions the pipeline
fed to CanvasProfileMatcher::resolveForSize(), captured via AppliedCanvasProfile and stored by
applyProcessingResults() (serialised additively; 0 = unknown).
detectCanvasConfigChange() then, for each input with known dimensions, resolves the profile it would
match now — the first profile of that W×H in the project's effective list (effectiveCanvasProfileIds(),
identical to the matcher's subA order) — and flags the page only if that profile's id or
canvasRenderFingerprint() differs from what was recorded. This catches every case precisely: a new
profile that now matches a previously-unmatched page ("" → id), a removed or reordered one, and an
in-place margin edit (same id, different fingerprint). Crucially the re-match considers only the
project's assigned (effective) profiles, never workspace-only ones — an unlinked same-size profile
returns FoundInWorkspaceOnly at render (the page is rendered without margins), so adding it must not, and
does not, desync the project. sanitize() marks exactly the affected inputs and the slices they fed
(Desynchronized).
The single W×H rule is Models::canvasSizeMatches(), shared by the matcher and this re-match so the two
can never drift. A page with no recorded dimensions (a legacy record rendered before dimensions were
tracked) cannot be re-matched, so it falls back to the coarse listChanged comparison
(effectiveCanvasProfileIds() != canvasProfileIdsAtRender) — the honest outcome, degrading to one full
re-render that records the dimensions and makes the project precise thereafter.
CanvasProfile::id and OutputProfile::id are random and unique within a workspace.
They are minted by Infrastructure::makeUniqueCanvasProfileId() / makeUniqueOutputProfileId()
(platemaker/infrastructure/id_generator/id_generator.hpp), which draw 128 random bits and
re-draw on collision with an existing id. An identifier is opaque: nothing parses it or derives
meaning from it. Presets carry fixed, well-known ids so a project can reference one across
sessions, but preset-ness is not derived from the id — it is provenance (catalogue membership),
see below.
The generator lives in Infrastructure, not Models or Core. It is not part of the data
model — it produces values rather than describing them — and it is not Core either, because
Core is deterministic domain logic (CanvasProfileMatcher, MarginCropper, Scaler all
return the same answer for the same input). Drawing on std::random_device makes it a
platform service in the same sense as the clock or the filesystem; CancellationToken sets
the precedent that Infrastructure here means platform-facing plumbing, not file I/O.
Two rules were dropped in 0.2.1 and must not be reintroduced:
- Timestamps as identifiers. Ids used to be a millisecond timestamp, so profiles minted inside one loop shared an id. A shared id makes the second profile unreachable — every lookup resolves to the first — which surfaces as a profile that cannot be assigned to a project (it counts as already assigned) and silently disappears from the assign list.
-
Deriving an id from the name (
"cp-" + name). A second identity scheme, and not unique either: two profiles with the same name collide.
WorkspaceSerializer::load() repairs both on the way in. Profiles with no id are given one
and the legacy "cp-<name>" / "op-<name>" references are relinked; profiles sharing an id
are separated, with the first keeping it so existing project references stay valid and
resolve to the same profile as before. The second overload reports the collisions it fixed
(WorkspaceRepairReport) so a GUI can explain the change; minting an absent id is not
reported, being unambiguous.
After a separation, which of the two profiles a project actually rendered with is unknowable
from the id alone. It is not guessed: ProjectEditor::sanitize() settles it exactly, by
comparing the canvasRenderFingerprint() recorded per input at render time.
Presets are baked into the build and never written to a workspace. The single source of
truth is a compile-time table, Models::k_outputPresetDefs in
platemaker/models/output_profile.hpp, from which full OutputProfile objects are materialised
on demand (webtoonStandardPreset(), outputProfilePresets()). Each preset has a fixed,
well-known id (e.g. op-preset-webtoon-standard) so a ProjectItem::outputProfileId can
reference one across sessions and app updates; the id is resolved against the catalogue at
runtime by Models::resolveOutputProfile(), which unions the workspace's own profiles with the
presets.
A preset is an OutputProfile — not a distinct type, and carrying no "is a preset" field.
Preset-ness is provenance: a profile is a preset exactly when it comes from the catalogue. A
consumer holding a bare id asks Models::outputPresetDefById(id) (a zero-copy membership test
over the compile-time table); a consumer listing profiles knows it from which source it read them
(outputProfilePresets() vs Workspace::outputProfiles). That is the deliberate reason the two
are kept separate rather than merged into one list, and why no flag or reserved id-prefix is
needed to tell them apart.
Because presets are code, not data:
-
Immutability is the library's own guarantee, not consumer discipline. There is no shared
mutable preset state to corrupt (the catalogue is rebuilt from code every run), and the write
path refuses preset identity —
WorkspaceSerializernever serialises anoutputProfilesentry whose id is a preset id, so a preset can be neither redefined nor smuggled into a workspace regardless of how a consumer drives the library. The GUI additionally disables Edit/Delete on a preset row and offers Duplicate; the CLI refusesmod/rmon a preset id. -
The developer may change a preset's (experimental) content in a later release without
affecting a user's own profiles — those are separate persisted data. A project that referenced
a preset directly renders with the new definition, and the existing
outputProfileSignature()staleness check flags its on-disk output as out of date; nothing changes silently. -
Growth is a pure code change: add a row to
k_outputPresetDefs. No migration, no stored-workspace impact.
Customising a preset is a duplicate into an ordinary, user-owned profile (a fresh random id).
load() migrates workspaces written by older builds that did persist presets: a stored profile
carrying a preset id (or the legacy "op-<name>" form) that still matches the preset is dropped,
and any project referencing it is relinked to the canonical id (resolved from the catalogue); a
profile that kept a preset id but has diverged is given a fresh id so no user profile can
masquerade as a preset. A user profile whose settings merely coincide with a preset is left
untouched. This migration is silent — unambiguous bookkeeping, not a collision — so it is not
reported in WorkspaceRepairReport.
Implemented API — and the invariant is now enforced, not merely guarded. The "future
WorkspaceEditor / ProjectEditor type" anticipated here exists as Infrastructure::WorkspaceEditor, the
single authority that mutates workspace profile state. The invariant-bearing fields are private in the
model, read through const accessors and written only through the editor / the guarded ProjectItem
methods:
-
Workspace::canvasProfiles()/outputProfiles()— the palettes (unique ids, no persisted presets); -
ProjectItem::canvasProfileIds()/outputProfileId()— the project links.
So a raw pi.canvasProfileIds.push_back() (bypassing the dimension guard) or pi.outputProfileId = "garbage" (bypassing id validation) no longer compiles. Linking is
WorkspaceEditor::addCanvasProfileToProject(project, profileId), which delegates to
ProjectItem::addCanvasProfile() and applies the dimension-collision guard; setProjectOutputProfile()
validates the id resolves (user profile or preset). WorkspaceSerializer (a friend) installs both
palettes and the project links at load time, running the same repair rules an in-session edit obeys.
(Workspace::projectItems and ProjectItem::name/uid/inputDirectory/outputSignature stay public
— they carry no cross-cutting invariant.)
Linking currently returns bool (linked / rejected); the richer result below — naming the specific
conflicting profile — remains a possible enhancement rather than a shipped shape:
struct AddCanvasProfileResult { // possible future enrichment of the bool return
enum class Status { Ok, Conflict };
Status status;
// Non-null only when status == Conflict.
const CanvasProfile* conflictingProfile = nullptr;
};
// Today: bool WorkspaceEditor::addCanvasProfileToProject(ProjectItem&, const std::string& profileId);
// Appends profileId to project.canvasProfileIds only when there is no dimension
// collision with any already-linked profile.CLI response on Conflict:
Error: canvas profile '<new-name>' (WxH) conflicts with already-linked
profile '<existing-name>' (WxH) [id: <existing-id>].
Hint: to replace it, run:
platemaker project rm-profile --project <NAME> --profile <existing-id>
then re-run the add command.
GUI response on Conflict: display an error and highlight the conflicting
profile entry in the project's canvas-profile list.
WorkspaceSerializer::save() may additionally assert the invariant as a safety
net (debug builds), but the primary enforcement is at add-time — save must never
be the first place a conflict is discovered.
Profiles at workspace level are allowed to share dimensions — the workspace is
a palette, not a processing list. The invariant only applies inside
ProjectItem.canvasProfileIds.
CanvasProfileMatcher is an object, not a bag of static functions. The
constructor performs the workspace partition once per project run; resolve() is
then called once per input image in O(N_project_ids) with no further allocation.
namespace Platemaker::Core {
struct ProfileMatchResult {
enum class Status {
Matched, // profile found in project list — use it
NotFoundAnywhere, // no matching profile in project or workspace
FoundInWorkspaceOnly, // found in workspace but not linked to project
};
Status status;
const CanvasProfile* profile = nullptr; // non-null only when Matched
std::vector<const CanvasProfile*> workspaceCandidates; // non-empty for FoundInWorkspaceOnly
};
class PLATEMAKER_EXPORT CanvasProfileMatcher {
public:
/// Partitions allWorkspaceProfiles into two ordered subsets once:
/// m_projectProfiles — profiles whose id ∈ projectProfileIds (priority order)
/// m_workspaceOnlyProfiles — the remainder
/// Construction is O(N_workspace × N_project_ids).
CanvasProfileMatcher(
const std::vector<CanvasProfile>& allWorkspaceProfiles,
const std::vector<std::string>& projectProfileIds); // ordered profile ids
/// Resolves the canvas profile for one input image of size \p width × \p height.
///
/// Search order:
/// 1. m_projectProfiles — O(N_project_ids); conflict guard guarantees
/// at most one match, so the first hit is final.
/// 2. m_workspaceOnlyProfiles — O(N_workspace - N_project_ids); only reached
/// when step 1 finds nothing.
///
/// Returns Matched / FoundInWorkspaceOnly / NotFoundAnywhere.
[[nodiscard]] ProfileMatchResult resolveForSize(int width, int height) const;
private:
std::vector<const CanvasProfile*> m_projectProfiles; // subA — priority order
std::vector<const CanvasProfile*> m_workspaceOnlyProfiles; // subB — fallback
};
} // namespace Platemaker::CoreTypical usage per processing run:
// Constructed once per project, before the page loop.
CanvasProfileMatcher matcher(workspace.canvasProfiles(), project.canvasProfileIds());
for (const auto& page : project.pages) {
auto [w, h] = readImageDimensions(page.filePath);
auto result = matcher.resolve(w, h);
// translate result.status into error / pipeline selection
}The CLI and GUI translate ProfileMatchResult::status into the user-facing messages
and suggested commands described in §7.5.1.
See also: Home · Workspace · Project · Render · Preview · Canvas-Profiles · Processing-Steps · Components · CLI · Platform · Roadmap · Diagrams