diff --git a/README.md b/README.md index 376a5a4..4e31dba 100644 --- a/README.md +++ b/README.md @@ -77,3 +77,7 @@ The [SysML-first plan](docs/sysml-v2-model-first-plan.md) and [implemented alpha The project uses deterministic program results for computable claims. Broader validation still requires evidence and engineering judgment. JSON is a transport format, not a token-efficiency claim; measure context and repair costs before making such claims. See [public references](docs/references.md) and [contribution guidance](CONTRIBUTING.md). External publications and tools remain under their own terms; none is bundled here. + +## Experimental visual presentation + +An opt-in checked documentary function-tree renderer adds palette03, embedded Inter/Space Grotesk fonts and explicit provisional body variants. See [visual presentation](docs/visual-presentation.md) for generation, editable sources, checks and limits. Existing checking defaults remain unchanged. diff --git a/assets/diagram-style/design-tokens.json b/assets/diagram-style/design-tokens.json new file mode 100644 index 0000000..29e0277 --- /dev/null +++ b/assets/diagram-style/design-tokens.json @@ -0,0 +1,551 @@ +{ + "schema": "engineering-visual-tokens/1", + "status": "SANITIZED DESIGN SPECIFICATION; NOT AN IMPLEMENTATION OR ACCEPTANCE RECORD", + "palette": { + "status": "SELECTED_COLLECTION", + "values": { + "id": "03", + "name": "Graphite and signal", + "paper": "#eeeeea", + "ink": "#25282d", + "families": [ + { + "name": "Signal blue", + "shade": "#27456f", + "base": "#32588e", + "tint": "#d2dae6" + }, + { + "name": "Brick red", + "shade": "#7f362f", + "base": "#a3453c", + "tint": "#ebd6d4" + }, + { + "name": "Amber", + "shade": "#825d19", + "base": "#a77720", + "tint": "#ece1ce" + }, + { + "name": "Instrument green", + "shade": "#315e43", + "base": "#3f7956", + "tint": "#d5e2da" + }, + { + "name": "Cyan blue", + "shade": "#2e6371", + "base": "#3b7f91", + "tint": "#d4e3e7" + }, + { + "name": "Violet", + "shade": "#58436f", + "base": "#71568e", + "tint": "#e0dae6" + }, + { + "name": "Tangerine", + "shade": "#894b25", + "base": "#b06030", + "tint": "#eedcd1" + }, + { + "name": "Berry red", + "shade": "#753c53", + "base": "#964d6b", + "tint": "#e8d8de" + } + ], + "neutrals": [ + { + "name": "Paper", + "hex": "#eeeeea" + }, + { + "name": "Light neutral", + "hex": "#dededb" + }, + { + "name": "Middle neutral", + "hex": "#b6b7b5" + }, + { + "name": "Ink", + "hex": "#25282d" + } + ] + }, + "universal_semantic_roles_selected": false, + "scoped_preference": { + "foreground": "#894b25", + "background": "#e0dae6", + "establishes_body_default": false + } + }, + "typography": { + "status": "SELECTED_FAMILY_ROLES", + "roles": { + "titles": "Inter", + "most_other_text": "Space Grotesk" + }, + "faces": [ + { + "family": "Inter", + "weight": 400, + "style": "normal", + "width": "normal", + "format": "woff2", + "package": "@fontsource/inter", + "package_version": "5.3.0", + "metadata_version": "v20", + "license": "SIL Open Font License 1.1", + "sha256": "8909904ab6c872eb994093482a88a28eca2cd95912d7b6fecd72103b0dc07edc" + }, + { + "family": "Inter", + "weight": 700, + "style": "normal", + "width": "normal", + "format": "woff2", + "package": "@fontsource/inter", + "package_version": "5.3.0", + "metadata_version": "v20", + "license": "SIL Open Font License 1.1", + "sha256": "6f56409fd3d64bb85f7d070bce20749db2d66b6d63cec586cc22d1c761be2491" + }, + { + "family": "Space Grotesk", + "weight": 400, + "style": "normal", + "width": "normal", + "format": "woff2", + "package": "@fontsource/space-grotesk", + "package_version": "5.3.0", + "metadata_version": "v22", + "license": "SIL Open Font License 1.1", + "sha256": "65fd17fcbd2e2f522940b5f67ead3d23329e02891aa5495e74d11a499c0b0673" + }, + { + "family": "Space Grotesk", + "weight": 700, + "style": "normal", + "width": "normal", + "format": "woff2", + "package": "@fontsource/space-grotesk", + "package_version": "5.3.0", + "metadata_version": "v22", + "license": "SIL Open Font License 1.1", + "sha256": "35f8aec56cfd5cbfdb03cc68733a54a0b05bb3617ffcd5fd332badc0b045ca55" + } + ], + "universal_weight_roles_selected": false, + "universal_sizes_selected": false, + "measurement": "Measure loaded glyphs at the delivered scale; preserve codepoints, subscripts and units. Report unsupported coverage.", + "fitting": "Do not force width, stretch, tracking or text-fitting transforms. Wrap or resize the content region. Preserve legitimate notation rotations. Uniform viewer zoom is separate." + }, + "illustration_profiles": { + "status": "SELECTED_STYLE_DIRECTION", + "selected": "Drafted front, profile and orthographic exterior cues", + "unselected_alternatives": [ + "Oblique relief", + "Compact silhouettes" + ], + "semantic_boundary": "Decorative cues do not establish physical geometry, dimensions, counts, containment or allocation.", + "universal_glyph_geometry_selected": false + }, + "containers": { + "status": "SELECTED_FORM_DIRECTION", + "form": "Square-corner content-fit single frame with useful information partitions", + "decorative_nested_frames": false, + "notation_precedence": true, + "example_geometry": { + "status": "ILLUSTRATIVE_NOT_UNIVERSAL", + "larger_container": { + "width": 420, + "height": 240, + "border": 2, + "side_key": 8, + "radius": 0 + }, + "smaller_container": { + "width": 400, + "height": 180, + "border": 2, + "side_key": 7, + "radius": 0 + }, + "unit": "CSS px", + "useful_divider_proposal": 1.5 + }, + "partition_layout": "Header, body and footer cells may share one frame when each carries useful information. They do not create model compartments or containment.", + "color_roles_status": "PROPOSED_NOT_UNIVERSALLY_SELECTED", + "purple_policy": "Retain a reworked purple option; no universal exclusion." + }, + "body_treatment": { + "status": "OPEN", + "selected": null, + "options": { + "white": "#ffffff", + "paper": "#eeeeea", + "faint": { + "violet": "#f6f5f8", + "blue": "#f3f5f8", + "tangerine": "#faf5f3", + "green": "#f3f7f5" + } + }, + "faint_formula": { + "alpha": 0.06, + "background": "#ffffff", + "per_sRGB_channel": "floor((1-alpha)*255 + alpha*baseChannel + 0.5)" + }, + "common_quantity_and_index_ink": "#25282d", + "policy": "Keep essential text, numbers and symbols visually distinct from the body. These options do not add palette families." + }, + "conditional_profiles": { + "general_sheet": { + "status": "RECOMMENDED_NOT_SELECTED", + "reference_canvas": { + "width": 1600, + "height": 1100, + "unit": "CSS px", + "zoom": 1 + }, + "ranges": { + "page_title": { + "min": 32, + "max": 48, + "default": 40, + "unit": "px", + "applicability": "1600px native sheet, title", + "exceptions": "Content fit and delivery scale" + }, + "node_title": { + "min": 24, + "max": 32, + "default": 28, + "unit": "px", + "applicability": "Native engineering-node title", + "exceptions": "Long labels: wrap/resize, preserve meaning" + }, + "body": { + "min": 20, + "max": 24, + "default": 22, + "unit": "px", + "applicability": "Native labels and body text", + "exceptions": "Narrow screens scroll/reflow; do not silently shrink" + }, + "annotation": { + "min": 18, + "max": 20, + "default": 18, + "unit": "px", + "applicability": "Secondary nonessential context", + "exceptions": "Never hide essential meaning in unreadable text" + }, + "padding": { + "min": 20, + "max": 32, + "default": 24, + "unit": "px", + "applicability": "Plain presentation box interior", + "exceptions": "Ports/labels/notation may need more" + }, + "related_gap": { + "min": 12, + "max": 24, + "default": 16, + "unit": "px", + "applicability": "Related plain content items", + "exceptions": "Measured labels may require larger gaps" + }, + "group_gap": { + "min": 32, + "max": 64, + "default": 40, + "unit": "px", + "applicability": "Distinct visual groups", + "exceptions": "Source routing/containment takes precedence" + }, + "label_clearance": { + "min": 8, + "max": 16, + "default": 12, + "unit": "px", + "applicability": "Label to essential route/terminal", + "exceptions": "Long text, branches, print scale" + }, + "bend_clearance": { + "min": 16, + "max": 32, + "default": 24, + "unit": "px", + "applicability": "Distance from bends/terminals to nearby obstacles or labels; never a route-to-endpoint gap", + "exceptions": "Endpoint connection gap is zero; preserve source attachment side/order and node boundaries" + }, + "container_width": { + "min": 320, + "max": 560, + "default": 400, + "unit": "px", + "applicability": "Simple untyped presentation container", + "exceptions": "Notation-specific nodes and content fit override" + }, + "line_weight": { + "min": 1.5, + "max": 3, + "default": 2, + "unit": "px", + "applicability": "Opaque essential strokes on native sheets", + "exceptions": "Source notation or deliberate emphasis may differ" + }, + "title_leading": { + "min": 1.15, + "max": 1.3, + "default": 1.2, + "unit": "ratio", + "applicability": "Multiline Inter title", + "exceptions": "Glyph/content measurement" + }, + "body_leading": { + "min": 1.35, + "max": 1.5, + "default": 1.4, + "unit": "ratio", + "applicability": "Multiline Space Grotesk body", + "exceptions": "Dense symbolic notation may need an explicit exception" + } + } + }, + "large_graphic_samples": { + "status": "RECOMMENDED_NOT_SELECTED", + "reference_width": 1600, + "unit": "CSS px", + "ranges": { + "line_weight": { + "min": 3, + "max": 6, + "default": 4, + "unit": "px", + "applicability": "Ordinary graphic samples at native 1600 px; source notation prevails", + "exceptions": "No automatic style change to typed relations" + }, + "compact_padding": { + "min": 12, + "max": 16, + "default": 14, + "unit": "px", + "applicability": "Content-fit plain containers", + "exceptions": "Source compartments/long labels require measurement" + }, + "standard_padding": { + "min": 18, + "max": 24, + "default": 20, + "unit": "px", + "applicability": "Plain content-fit containers", + "exceptions": "Nested ports and labels may need more space" + } + }, + "illustrative_text_sizes": { + "titles": [ + 28, + 32, + 44 + ], + "body": [ + 20, + 22, + 24 + ] + } + }, + "resolution": "Profiles apply to different content and scale conditions. Do not combine them into a universal range. Model notation, content fit and delivery scale take precedence." + }, + "line_and_texture_samples": { + "status": "VISUAL_DIRECTION_WITH_UNSELECTED_SEMANTIC_ROLES", + "line_samples": [ + { + "name": "Solid", + "weight": 4, + "dasharray": "", + "color": "#27456f" + }, + { + "name": "Dash", + "weight": 4, + "dasharray": "26 12", + "color": "#894b25" + }, + { + "name": "Dot", + "weight": 6, + "dasharray": "0 17", + "color": "#315e43" + }, + { + "name": "Dash-dot", + "weight": 4, + "dasharray": "26 10 2 10", + "color": "#7f362f" + }, + { + "name": "Spotted", + "weight": null, + "dasharray": null, + "color": "#27456f" + }, + { + "name": "Striped band", + "weight": null, + "dasharray": null, + "color": "#27456f" + }, + { + "name": "Color channels", + "weight": null, + "dasharray": null, + "color": "#315e43" + } + ], + "unit": "CSS px at the large graphic reference scale", + "universal_relation_mapping_selected": false, + "texture_policy": "Exploratory decoration only in nonessential regions. Preserve declared material or equipment hatches and stipple. Decorative patterns do not establish material properties or relation types.", + "essential_content": "Use opaque contrasting fields and retain noncolor distinctions." + }, + "diagram_families": { + "labels": [ + "System context and interconnection", + "Composition and hierarchy", + "Functional flow", + "Activity", + "State machine", + "Sequence", + "Electrical and electronic schematic", + "Concept illustration", + "Traceability matrix", + "Physical geometry" + ], + "status": "DESCRIPTIVE LABELS ONLY; NOT A PRODUCT SUPPORT MATRIX", + "support_rule": "Each supported family needs an explicit admitted model contract, notation rules and tests. Unsupported families remain unsupported." + }, + "notation_invariants": [ + "The authoritative engineering model determines element identity, ownership, type, applicability, units, thresholds and obligations.", + "Preserve declared relation direction and kind, port identities, endpoint attachments, side and order, multiplicity and cardinality.", + "Keep junctions distinct from independent crossings; do not infer a junction from a visual intersection.", + "Use arrowheads only where declared direction or sequence warrants them. Preserve composition-diamond ownership, control versus object flow, fork/join and state-exit semantics.", + "Preserve declared resistor, transistor, switch and other specialized notation; a decorative pictogram is not a typed model element.", + "Actual connections meet their declared contacts. Clearance applies to labels and obstacles. Preserve explicitly nonattached marks and documented gaps without inventing links.", + "Do not create model containment, allocations or counts from boxes, facets, fasteners, icon marks or visual groupings.", + "Unknown or ambiguous meaning remains explicit. An illustration does not become a formal systems model through styling." + ], + "crispness_method": { + "goal": "Legible text, deliberate stroke hierarchy and clear separation at the delivery scale", + "measurement": [ + "Compare baseline and candidate at the same canvas, browser renderer, device-pixel ratio and display scale.", + "Compute effective painted widths through scene, element, pattern and instance transforms; nominal attributes alone are insufficient.", + "Inspect text bounds, clipping, overlaps, arrowheads, dash patterns, hatch density, connections and crossings.", + "Preserve editable vectors and text with qualified embedded fonts. Generate higher-resolution rasters by fresh vector rendering.", + "Use normal antialiasing for curves and diagonals; do not apply a universal crispEdges override." + ], + "limits": [ + "A thicker or thinner stroke does not universally improve sharpness.", + "Thumbnail fit factors do not establish equal text size or physical scale.", + "Nominal CSS pixels per inch do not calibrate a monitor or printer.", + "Visual preference and numerical checks do not establish engineering acceptance or reader comprehension." + ] + }, + "acceptance_proposals": { + "status": "PROPOSED; EXECUTION AND ENGINEER ACCEPTANCE NOT CLAIMED", + "gates": [ + { + "name": "Model projection", + "expectation": "Check identity, ownership, endpoint kinds, topology, notation, units, cardinalities and text against the admitted model." + }, + { + "name": "Meaningful refusal tests", + "expectation": "Use synthetic negative fixtures for wrong endpoints, missing required inputs, altered model inputs and unsupported notation. Required unknown, errored or incomplete checks cannot yield overall success." + }, + { + "name": "Typography", + "expectation": "Check actual loaded faces, glyph coverage, codepoints, subscripts, metrics, font notices and absence of silent fallback." + }, + { + "name": "Layout and contrast", + "expectation": "Check clipping, collisions, label and port clearances, contact attachment, arrowheads and measurable contrast at intended scales. Do not use color alone for essential meaning." + }, + { + "name": "Artifact generation", + "expectation": "Regenerate from trusted model data; inspect native vectors and fresh raster exports. If offline viewing is delivered, test it without network resources and verify saved source identity." + }, + { + "name": "Repository integration", + "expectation": "Run required scoped and repository regressions on the assigned integration base. Preserve existing public interfaces and reject execution of code embedded in model data." + }, + { + "name": "Review and acceptance", + "expectation": "Keep semantic review, visual review, engineering acceptance and physical qualification separate. Hashes identify bytes rather than authenticate decisions." + }, + { + "name": "Reuse rights", + "expectation": "Use synthetic fixtures, original reusable artwork or explicitly permitted attributed derivatives. Include required licenses with redistributed fonts and assets." + } + ], + "additional_qualification": [ + "Graphical-editor edit, save and reopen where editable delivery is claimed.", + "Print, grayscale and monitor-scale qualification for the intended medium.", + "Accessibility and representative reader-comprehension evaluation." + ] + }, + "delivery_boundary": "This document contains reusable presentation values and proposals only. It embeds no source figures, models, artwork, font binaries or evidence records and grants no rights to omitted material.", + "function_tree_implementation": { + "status": "SCOPED_LOCAL_DEFAULTS_NOT_UNIVERSAL_SEMANTICS", + "profile_field": "function_tree_roles", + "roles": { + "status": "slice_specific_proposal", + "root_pair": "orange_on_violet", + "branch_pairs": [ + "blue_on_blue", + "orange_on_orange", + "green_on_green" + ], + "shared_fanout": "ink", + "pairs": { + "orange_on_violet": { + "fill": "violet.tint", + "frame": "tangerine.shade", + "connector": "tangerine.shade" + }, + "brick_on_cream": { + "fill": "amber.tint", + "frame": "red.shade", + "connector": "red.shade" + }, + "green_on_cool": { + "fill": "cyan.tint", + "frame": "green.shade", + "connector": "green.shade" + }, + "blue_on_blue": { + "fill": "blue.tint", + "frame": "blue.shade", + "connector": "blue.shade" + }, + "orange_on_orange": { + "fill": "tangerine.tint", + "frame": "tangerine.shade", + "connector": "tangerine.shade" + }, + "green_on_green": { + "fill": "green.tint", + "frame": "green.shade", + "connector": "green.shade" + } + } + }, + "scope": "Current selected compact tree treatment restores same-family blue, tangerine and green branches with an orange-on-violet root. Independent fill, frame/accent and connector resolution and nondefault pair candidates remain available. No universal engineering meaning; leaf body choices remain provisional." + } +} diff --git a/assets/diagram-style/fonts/inter-400.woff2 b/assets/diagram-style/fonts/inter-400.woff2 new file mode 100644 index 0000000..f15b025 Binary files /dev/null and b/assets/diagram-style/fonts/inter-400.woff2 differ diff --git a/assets/diagram-style/fonts/inter-700.woff2 b/assets/diagram-style/fonts/inter-700.woff2 new file mode 100644 index 0000000..a68fb10 Binary files /dev/null and b/assets/diagram-style/fonts/inter-700.woff2 differ diff --git a/assets/diagram-style/fonts/inter-OFL.txt b/assets/diagram-style/fonts/inter-OFL.txt new file mode 100644 index 0000000..40589da --- /dev/null +++ b/assets/diagram-style/fonts/inter-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/diagram-style/fonts/manifest.json b/assets/diagram-style/fonts/manifest.json new file mode 100644 index 0000000..8a44d1d --- /dev/null +++ b/assets/diagram-style/fonts/manifest.json @@ -0,0 +1,67 @@ +{ + "version": 1, + "license": "OFL-1.1; each original notice retained", + "faces": [ + { + "family": "Inter", + "weight": 400, + "file": "inter-400.woff2", + "sha256": "8909904ab6c872eb994093482a88a28eca2cd95912d7b6fecd72103b0dc07edc", + "bytes": 23664, + "notice": "inter-OFL.txt", + "notice_sha256": "3b0a5fca3d17942cde889069889dedbbbd075e9b599968c82a95f4d944e9b345", + "package": "@fontsource/inter", + "package_version": "5.3.0", + "source_file": "files/inter-latin-400-normal.woff2", + "unicode_range": "U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD", + "source_url": "https://www.npmjs.com/package/@fontsource/inter/v/5.3.0", + "modifications": "none" + }, + { + "family": "Inter", + "weight": 700, + "file": "inter-700.woff2", + "sha256": "6f56409fd3d64bb85f7d070bce20749db2d66b6d63cec586cc22d1c761be2491", + "bytes": 24356, + "notice": "inter-OFL.txt", + "notice_sha256": "3b0a5fca3d17942cde889069889dedbbbd075e9b599968c82a95f4d944e9b345", + "package": "@fontsource/inter", + "package_version": "5.3.0", + "source_file": "files/inter-latin-700-normal.woff2", + "unicode_range": "U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD", + "source_url": "https://www.npmjs.com/package/@fontsource/inter/v/5.3.0", + "modifications": "none" + }, + { + "family": "Space Grotesk", + "weight": 400, + "file": "space-grotesk-400.woff2", + "sha256": "65fd17fcbd2e2f522940b5f67ead3d23329e02891aa5495e74d11a499c0b0673", + "bytes": 13388, + "notice": "space-OFL.txt", + "notice_sha256": "18a4de52385f6b988782639d5d0cc1326e5a8c2de9a7f01d7b20d9aedcc60943", + "package": "@fontsource/space-grotesk", + "package_version": "5.3.0", + "source_file": "files/space-grotesk-latin-400-normal.woff2", + "unicode_range": "U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD", + "source_url": "https://www.npmjs.com/package/@fontsource/space-grotesk/v/5.3.0", + "modifications": "none" + }, + { + "family": "Space Grotesk", + "weight": 700, + "file": "space-grotesk-700.woff2", + "sha256": "35f8aec56cfd5cbfdb03cc68733a54a0b05bb3617ffcd5fd332badc0b045ca55", + "bytes": 12840, + "notice": "space-OFL.txt", + "notice_sha256": "18a4de52385f6b988782639d5d0cc1326e5a8c2de9a7f01d7b20d9aedcc60943", + "package": "@fontsource/space-grotesk", + "package_version": "5.3.0", + "source_file": "files/space-grotesk-latin-700-normal.woff2", + "unicode_range": "U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD", + "source_url": "https://www.npmjs.com/package/@fontsource/space-grotesk/v/5.3.0", + "modifications": "none" + } + ], + "coverage": "This renderer slice qualifies printable ASCII plus middle dot; broader declared font ranges are not a tested glyph-coverage claim." +} diff --git a/assets/diagram-style/fonts/metrics.json b/assets/diagram-style/fonts/metrics.json new file mode 100644 index 0000000..91473e6 --- /dev/null +++ b/assets/diagram-style/fonts/metrics.json @@ -0,0 +1,415 @@ +{ + "version": 1, + "method": "Canvas measureText at 1000px; font-kerning none; no ligatures; printable ASCII plus middle dot", + "renderer": "143.0.7499.4", + "faces": { + "Inter:400": { + "font_sha256": "8909904ab6c872eb994093482a88a28eca2cd95912d7b6fecd72103b0dc07edc", + "units": 1000, + "advances": { + "0": 631, + "1": 407, + "2": 610, + "3": 618, + "4": 646, + "5": 593, + "6": 620, + "7": 566, + "8": 619, + "9": 620, + " ": 281, + "!": 288, + "\"": 466, + "#": 633, + "$": 642, + "%": 982, + "&": 644, + "'": 300, + "(": 365, + ")": 365, + "*": 501, + "+": 662, + ",": 288, + "-": 460, + ".": 288, + "/": 360, + ":": 288, + ";": 302, + "<": 662, + "=": 662, + ">": 662, + "?": 511, + "@": 966, + "A": 690, + "B": 654, + "C": 730, + "D": 722, + "E": 601, + "F": 590, + "G": 746, + "H": 743, + "I": 269, + "J": 571, + "K": 672, + "L": 565, + "M": 903, + "N": 753, + "O": 765, + "P": 639, + "Q": 765, + "R": 644, + "S": 642, + "T": 646, + "U": 744, + "V": 690, + "W": 985, + "X": 682, + "Y": 679, + "Z": 629, + "[": 365, + "\\": 360, + "]": 365, + "^": 471, + "_": 456, + "`": 323, + "a": 562, + "b": 612, + "c": 571, + "d": 612, + "e": 583, + "f": 370, + "g": 613, + "h": 591, + "i": 242, + "j": 242, + "k": 549, + "l": 242, + "m": 876, + "n": 591, + "o": 600, + "p": 612, + "q": 612, + "r": 376, + "s": 528, + "t": 327, + "u": 591, + "v": 562, + "w": 818, + "x": 546, + "y": 562, + "z": 552, + "{": 426, + "|": 333, + "}": 426, + "~": 662, + "·": 288 + } + }, + "Inter:700": { + "font_sha256": "6f56409fd3d64bb85f7d070bce20749db2d66b6d63cec586cc22d1c761be2491", + "units": 1000, + "advances": { + "0": 674, + "1": 431, + "2": 630, + "3": 646, + "4": 676, + "5": 622, + "6": 649, + "7": 582, + "8": 651, + "9": 649, + " ": 237, + "!": 338, + "\"": 552, + "#": 649, + "$": 655, + "%": 1016, + "&": 672, + "'": 339, + "(": 377, + ")": 377, + "*": 559, + "+": 679, + ",": 334, + "-": 468, + ".": 334, + "/": 388, + ":": 334, + ";": 343, + "<": 679, + "=": 679, + ">": 679, + "?": 560, + "@": 1016, + "A": 747, + "B": 662, + "C": 740, + "D": 722, + "E": 607, + "F": 587, + "G": 750, + "H": 747, + "I": 281, + "J": 584, + "K": 719, + "L": 565, + "M": 932, + "N": 762, + "O": 771, + "P": 648, + "Q": 777, + "R": 657, + "S": 655, + "T": 667, + "U": 732, + "V": 747, + "W": 1038, + "X": 738, + "Y": 731, + "Z": 664, + "[": 377, + "\\": 388, + "]": 377, + "^": 487, + "_": 476, + "`": 365, + "a": 581, + "b": 630, + "c": 588, + "d": 630, + "e": 596, + "f": 398, + "g": 632, + "h": 623, + "i": 271, + "j": 271, + "k": 580, + "l": 271, + "m": 913, + "n": 623, + "o": 613, + "p": 630, + "q": 630, + "r": 407, + "s": 560, + "t": 366, + "u": 623, + "v": 600, + "w": 850, + "x": 580, + "y": 602, + "z": 573, + "{": 469, + "|": 372, + "}": 469, + "~": 679, + "·": 334 + } + }, + "Space Grotesk:400": { + "font_sha256": "65fd17fcbd2e2f522940b5f67ead3d23329e02891aa5495e74d11a499c0b0673", + "units": 1000, + "advances": { + "0": 641, + "1": 418, + "2": 603, + "3": 604, + "4": 625, + "5": 599, + "6": 612, + "7": 564, + "8": 623, + "9": 612, + " ": 257, + "!": 253, + "\"": 425, + "#": 628, + "$": 613, + "%": 772, + "&": 604, + "'": 251, + "(": 379, + ")": 377, + "*": 520, + "+": 620, + ",": 251, + "-": 455, + ".": 253, + "/": 370, + ":": 253, + ";": 253, + "<": 620, + "=": 620, + ">": 620, + "?": 580, + "@": 1026, + "A": 630, + "B": 661, + "C": 642, + "D": 663, + "E": 563, + "F": 540, + "G": 660, + "H": 656, + "I": 244, + "J": 594, + "K": 610, + "L": 546, + "M": 865, + "N": 664, + "O": 669, + "P": 601, + "Q": 669, + "R": 631, + "S": 613, + "T": 588, + "U": 666, + "V": 619, + "W": 875, + "X": 638, + "Y": 610, + "Z": 577, + "[": 335, + "\\": 370, + "]": 335, + "^": 620, + "_": 620, + "`": 280, + "a": 575, + "b": 644, + "c": 598, + "d": 644, + "e": 592, + "f": 442, + "g": 642, + "h": 613, + "i": 245, + "j": 247, + "k": 538, + "l": 245, + "m": 860, + "n": 613, + "o": 618, + "p": 644, + "q": 644, + "r": 377, + "s": 527, + "t": 460, + "u": 612, + "v": 539, + "w": 801, + "x": 593, + "y": 612, + "z": 521, + "{": 432, + "|": 240, + "}": 432, + "~": 620, + "·": 187 + } + }, + "Space Grotesk:700": { + "font_sha256": "35f8aec56cfd5cbfdb03cc68733a54a0b05bb3617ffcd5fd332badc0b045ca55", + "units": 1000, + "advances": { + "0": 648, + "1": 452, + "2": 594, + "3": 608, + "4": 636, + "5": 600, + "6": 618, + "7": 554, + "8": 600, + "9": 618, + " ": 254, + "!": 298, + "\"": 514, + "#": 636, + "$": 606, + "%": 758, + "&": 591, + "'": 294, + "(": 398, + ")": 390, + "*": 540, + "+": 620, + ",": 294, + "-": 432, + ".": 298, + "/": 388, + ":": 298, + ";": 298, + "<": 620, + "=": 620, + ">": 620, + "?": 578, + "@": 1014, + "A": 634, + "B": 664, + "C": 644, + "D": 666, + "E": 554, + "F": 534, + "G": 662, + "H": 656, + "I": 264, + "J": 610, + "K": 626, + "L": 542, + "M": 882, + "N": 670, + "O": 676, + "P": 604, + "Q": 676, + "R": 632, + "S": 606, + "T": 588, + "U": 672, + "V": 618, + "W": 898, + "X": 644, + "Y": 624, + "Z": 576, + "[": 358, + "\\": 388, + "]": 358, + "^": 620, + "_": 620, + "`": 296, + "a": 578, + "b": 638, + "c": 586, + "d": 638, + "e": 577, + "f": 436, + "g": 638, + "h": 616, + "i": 266, + "j": 268, + "k": 564, + "l": 266, + "m": 854, + "n": 616, + "o": 612, + "p": 638, + "q": 638, + "r": 396, + "s": 524, + "t": 456, + "u": 616, + "v": 548, + "w": 784, + "x": 592, + "y": 616, + "z": 518, + "{": 466, + "|": 258, + "}": 466, + "~": 620, + "·": 218 + } + } + } +} diff --git a/assets/diagram-style/fonts/space-OFL.txt b/assets/diagram-style/fonts/space-OFL.txt new file mode 100644 index 0000000..d5666d7 --- /dev/null +++ b/assets/diagram-style/fonts/space-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/diagram-style/fonts/space-grotesk-400.woff2 b/assets/diagram-style/fonts/space-grotesk-400.woff2 new file mode 100644 index 0000000..0e63471 Binary files /dev/null and b/assets/diagram-style/fonts/space-grotesk-400.woff2 differ diff --git a/assets/diagram-style/fonts/space-grotesk-700.woff2 b/assets/diagram-style/fonts/space-grotesk-700.woff2 new file mode 100644 index 0000000..44604a0 Binary files /dev/null and b/assets/diagram-style/fonts/space-grotesk-700.woff2 differ diff --git a/assets/diagram-style/profile.json b/assets/diagram-style/profile.json new file mode 100644 index 0000000..de2f2e8 --- /dev/null +++ b/assets/diagram-style/profile.json @@ -0,0 +1,117 @@ +{ + "version": 1, + "name": "Graphite and signal", + "direction_status": "selected_direction", + "fonts": { + "title": "Inter", + "body": "Space Grotesk" + }, + "body_variants": { + "status": "provisional", + "values": [ + "paper", + "white", + "tint" + ] + }, + "numeric_proposal": { + "status": "slice_specific_proposal", + "title_size": 36, + "body_size": 22, + "stroke_display_px": 2 + }, + "limits": { + "glyphs": "printable_ascii_plus_middle_dot", + "color_semantics": "none" + }, + "palette": { + "paper": "#eeeeea", + "ink": "#25282d", + "light": "#dededb", + "middle": "#b6b7b5", + "families": { + "blue": { + "shade": "#27456f", + "base": "#32588e", + "tint": "#d2dae6" + }, + "red": { + "shade": "#7f362f", + "base": "#a3453c", + "tint": "#ebd6d4" + }, + "amber": { + "shade": "#825d19", + "base": "#a77720", + "tint": "#ece1ce" + }, + "green": { + "shade": "#315e43", + "base": "#3f7956", + "tint": "#d5e2da" + }, + "cyan": { + "shade": "#2e6371", + "base": "#3b7f91", + "tint": "#d4e3e7" + }, + "violet": { + "shade": "#58436f", + "base": "#71568e", + "tint": "#e0dae6" + }, + "tangerine": { + "shade": "#894b25", + "base": "#b06030", + "tint": "#eedcd1" + }, + "berry": { + "shade": "#753c53", + "base": "#964d6b", + "tint": "#e8d8de" + } + } + }, + "function_tree_roles": { + "status": "slice_specific_proposal", + "root_pair": "orange_on_violet", + "branch_pairs": [ + "blue_on_blue", + "orange_on_orange", + "green_on_green" + ], + "shared_fanout": "ink", + "pairs": { + "orange_on_violet": { + "fill": "violet.tint", + "frame": "tangerine.shade", + "connector": "tangerine.shade" + }, + "brick_on_cream": { + "fill": "amber.tint", + "frame": "red.shade", + "connector": "red.shade" + }, + "green_on_cool": { + "fill": "cyan.tint", + "frame": "green.shade", + "connector": "green.shade" + }, + "blue_on_blue": { + "fill": "blue.tint", + "frame": "blue.shade", + "connector": "blue.shade" + }, + "orange_on_orange": { + "fill": "tangerine.tint", + "frame": "tangerine.shade", + "connector": "tangerine.shade" + }, + "green_on_green": { + "fill": "green.tint", + "frame": "green.shade", + "connector": "green.shade" + } + } + } +} diff --git a/docs/visual-design-spec.md b/docs/visual-design-spec.md new file mode 100644 index 0000000..be9ab30 --- /dev/null +++ b/docs/visual-design-spec.md @@ -0,0 +1,109 @@ +# Portable engineering visual specification + +This specification records reusable presentation choices and proposed checks. It is suitable for implementation documentation, subject to engineering review. It does not claim implemented support, verified behavior or accepted engineering meaning. + +## Decision status + +Selected directions are the palette below, Inter titles, Space Grotesk for most other text, drafted front/profile/orthographic exterior cues, square-corner content-fit framing and useful information partitions. Oblique relief and compact silhouettes are unselected alternatives. Illustration cues do not establish physical geometry, dimensions, counts, containment or allocations. + +White, paper and faint-tint box bodies remain open. Numerical sizes, spacing and weights are conditional recommendations. Universal line, texture and color meanings remain unselected. A scoped preference for orange `#894b25` on violet tint `#e0dae6` does not establish a universal body fill or engineering semantic role. + +## Palette + +| Family | Shade | Base | Tint | +| --- | --- | --- | --- | +| Signal blue | `#27456f` | `#32588e` | `#d2dae6` | +| Brick red | `#7f362f` | `#a3453c` | `#ebd6d4` | +| Amber | `#825d19` | `#a77720` | `#ece1ce` | +| Instrument green | `#315e43` | `#3f7956` | `#d5e2da` | +| Cyan blue | `#2e6371` | `#3b7f91` | `#d4e3e7` | +| Violet | `#58436f` | `#71568e` | `#e0dae6` | +| Tangerine | `#894b25` | `#b06030` | `#eedcd1` | +| Berry red | `#753c53` | `#964d6b` | `#e8d8de` | + +Neutrals are paper `#eeeeea`, light `#dededb`, middle `#b6b7b5` and ink `#25282d`. White `#ffffff` is a body option, not another palette family. Essential labels need opaque contrasting fields and distinctions that do not depend on color alone. + +## Typography + +Use Inter for titles and Space Grotesk for most other text. Normal-width regular and bold faces are available; universal weight roles and numerical sizes remain open. The identified WOFF2 faces use SIL Open Font License 1.1. Package metadata is @fontsource 5.3.0, with Inter v20 and Space Grotesk v22 metadata. Include the applicable upstream copyright and license notices when redistributing font files; no binaries are included here. + +| Family | Weight | Face SHA-256 | +| --- | --- | --- | +| Inter | 400 | `8909904ab6c872eb994093482a88a28eca2cd95912d7b6fecd72103b0dc07edc` | +| Inter | 700 | `6f56409fd3d64bb85f7d070bce20749db2d66b6d63cec586cc22d1c761be2491` | +| Space Grotesk | 400 | `65fd17fcbd2e2f522940b5f67ead3d23329e02891aa5495e74d11a499c0b0673` | +| Space Grotesk | 700 | `35f8aec56cfd5cbfdb03cc68733a54a0b05bb3617ffcd5fd332badc0b045ca55` | + +Measure actual loaded glyphs. Preserve codepoints, scientific symbols, subscripts and units; explicitly qualify any supplement and report unsupported coverage. Do not force text width, stretch, tracking or fitting transforms. Wrap or resize the content region. Preserve legitimate notation rotations. Uniform viewer zoom is a separate presentation operation. + +## Framing and open body choices + +Use one content-fit frame with meaningful partitions. Header, body and footer cells are useful when each carries information. Decorative nested frames must not imply engineering containment. Native notation and model-owned compartments take precedence. Retain a reworked purple option; there is no universal purple exclusion. + +Illustrative plain containers are 420 × 240 and 400 × 180 CSS px, with 2 px borders, square corners and optional 8 px or 7 px side keys. These dimensions are examples, not required defaults. A 1.5 px divider is proposed only for useful partitions. + +Open bodies are white `#ffffff`, paper `#eeeeea`, or faint tints: violet `#f6f5f8`, blue `#f3f5f8`, tangerine `#faf5f3`, green `#f3f7f5`. The faint proposal uses 6% of an existing base over white: `floor((1-alpha)*255 + alpha*baseChannel + 0.5)` per sRGB channel, with alpha 0.06. It adds no palette family. Common quantity and index text uses ink `#25282d`; keep essential text, numbers and symbols visually distinct from the body. + +## Conditional ranges + +These recommendations assume a 1600 × 1100 CSS px authored sheet at zoom 1. They are presentation units, never physical engineering dimensions. Retain the applicability and exception for each range. + +| Parameter | Range | Starting value | Unit | Applicability and exception | +| --- | --- | --- | --- | --- | +| page title | 32–48 | 40 | px | 1600px native sheet, title; Content fit and delivery scale | +| node title | 24–32 | 28 | px | Native engineering-node title; Long labels: wrap/resize, preserve meaning | +| body | 20–24 | 22 | px | Native labels and body text; Narrow screens scroll/reflow; do not silently shrink | +| annotation | 18–20 | 18 | px | Secondary nonessential context; Never hide essential meaning in unreadable text | +| padding | 20–32 | 24 | px | Plain presentation box interior; Ports/labels/notation may need more | +| related gap | 12–24 | 16 | px | Related plain content items; Measured labels may require larger gaps | +| group gap | 32–64 | 40 | px | Distinct visual groups; Source routing/containment takes precedence | +| label clearance | 8–16 | 12 | px | Label to essential route/terminal; Long text, branches, print scale | +| bend clearance | 16–32 | 24 | px | Distance from bends/terminals to nearby obstacles or labels; never a route-to-endpoint gap; Endpoint connection gap is zero; preserve source attachment side/order and node boundaries | +| container width | 320–560 | 400 | px | Simple untyped presentation container; Notation-specific nodes and content fit override | +| line weight | 1.5–3 | 2 | px | Opaque essential strokes on native sheets; Source notation or deliberate emphasis may differ | +| title leading | 1.15–1.3 | 1.2 | ratio | Multiline Inter title; Glyph/content measurement | +| body leading | 1.35–1.5 | 1.4 | ratio | Multiline Space Grotesk body; Dense symbolic notation may need an explicit exception | + +A separate large-graphic profile at 1600 CSS px width proposes 3–6 px strokes, starting at 4; compact padding 12–16 px, starting at 14; and standard padding 18–24 px, starting at 20. Illustrative title sizes are 28, 32 and 44 px; body sizes are 20, 22 and 24 px. This profile applies to large graphic samples. Do not combine it with the general-sheet profile into a universal range or silently change typed notation. + +## Lines, textures and notation + +Solid, dashed, dotted, dash-dot, spotted, striped-band and color-channel samples are available visual directions. Universal relation mappings are unselected. At the large-graphic reference scale, illustrative strokes are solid 4 px; dashed 4 px with array `26 12`; dotted 6 px with array `0 17`; and dash-dot 4 px with array `26 10 2 10`. Dotted samples require an explicitly qualified cap treatment. Preserve native notation instead of imposing these arrays globally. + +Textures are exploratory decoration in nonessential regions. Preserve declared equipment hatches, material stipple and other semantic patterns. Decorative textures do not establish material properties or relation types. + +- The authoritative engineering model determines element identity, ownership, type, applicability, units, thresholds and obligations. +- Preserve declared relation direction and kind, port identities, endpoint attachments, side and order, multiplicity and cardinality. +- Keep junctions distinct from independent crossings; do not infer a junction from a visual intersection. +- Use arrowheads only where declared direction or sequence warrants them. Preserve composition-diamond ownership, control versus object flow, fork/join and state-exit semantics. +- Preserve declared resistor, transistor, switch and other specialized notation; a decorative pictogram is not a typed model element. +- Actual connections meet their declared contacts. Clearance applies to labels and obstacles. Preserve explicitly nonattached marks and documented gaps without inventing links. +- Do not create model containment, allocations or counts from boxes, facets, fasteners, icon marks or visual groupings. +- Unknown or ambiguous meaning remains explicit. An illustration does not become a formal systems model through styling. + +Public diagram-family descriptions include system context/interconnection, composition/hierarchy, functional flow, activity, state machine, sequence, electrical/electronic schematic, concept illustration, traceability matrix and physical geometry. These labels are not a support matrix. Each supported family needs an explicit admitted model contract, notation rules and tests. Unsupported families remain unsupported. + +## Crispness method + +Compare baseline and candidate at the same canvas, browser renderer, device-pixel ratio and display scale. Compute effective painted widths through scene, element, pattern and instance transforms; raw stroke attributes alone are insufficient. Inspect text bounds, clipping, overlaps, arrows, dashes, hatch density, connections and crossings at the delivery scale. + +Preserve editable vectors and text with qualified embedded fonts. Generate higher-resolution rasters by fresh vector rendering. Curves and diagonals use normal antialiasing; do not impose a universal crispEdges override. Thicker or thinner strokes do not universally improve sharpness. Thumbnail fit factors cannot establish equal text size, and nominal CSS pixels per inch do not calibrate a monitor or printer. + +## Proposed acceptance checks + +The following are integration proposals. Their inclusion does not report execution or engineer acceptance. + +- **Model projection:** Check identity, ownership, endpoint kinds, topology, notation, units, cardinalities and text against the admitted model. +- **Meaningful refusal tests:** Use synthetic negative fixtures for wrong endpoints, missing required inputs, altered model inputs and unsupported notation. Required unknown, errored or incomplete checks cannot yield overall success. +- **Typography:** Check actual loaded faces, glyph coverage, codepoints, subscripts, metrics, font notices and absence of silent fallback. +- **Layout and contrast:** Check clipping, collisions, label and port clearances, contact attachment, arrowheads and measurable contrast at intended scales. Do not use color alone for essential meaning. +- **Artifact generation:** Regenerate from trusted model data; inspect native vectors and fresh raster exports. If offline viewing is delivered, test it without network resources and verify saved source identity. +- **Repository integration:** Run required scoped and repository regressions on the assigned integration base. Preserve existing public interfaces and reject execution of code embedded in model data. +- **Review and acceptance:** Keep semantic review, visual review, engineering acceptance and physical qualification separate. Hashes identify bytes rather than authenticate decisions. +- **Reuse rights:** Use synthetic fixtures, original reusable artwork or explicitly permitted attributed derivatives. Include required licenses with redistributed fonts and assets. + +Where relevant, separately qualify graphical-editor edit/save/reopen, print and grayscale behavior, monitor scale, accessibility and representative reader comprehension. Numerical passes and visual preference do not establish physical correctness or engineering acceptance. + +## Reuse boundary + +This specification includes presentation tokens and generic engineering rules. It embeds no source figures, models, artwork, font binaries or evidence records and grants no rights to omitted material. Use synthetic fixtures, original reusable artwork or explicitly permitted attributed derivatives for implementation examples. diff --git a/docs/visual-presentation.md b/docs/visual-presentation.md new file mode 100644 index 0000000..a6bc036 --- /dev/null +++ b/docs/visual-presentation.md @@ -0,0 +1,79 @@ +# Opt-in visual presentation + +This experimental slice renders one checked documentary function tree from the existing native-bound `CSE-VIEWS/1.0` contract. It adds presentation, not engineering meaning or behavioral verification. Existing model checking and JSON context commands keep their defaults. + +## Generate and revise + +```sh +python3 scripts/sysml_present.py examples/sysml/views/sample-handling.sysml --view FT --output work/tree-paper --body paper --target-width 1200 +``` + +Use the CLI help for the current flags. The output directory must be new. The native parser and view checks must pass before any diagram is emitted. A function tree is an explicit documentary decomposition; it is not an activity sequence, allocation, native port network or simulation. Without a label companion, the drawing leads with exact native action names and the human title “Function decomposition”. An explicit `--labels` companion supplies separately authored display wording bound to those exact native identities; it never renames the authoritative model. Internal view/node IDs and level tags are hidden by default; add `--show-ids` to display the view ID and place node metadata after each native name. Legitimate identifiers within native names are retained verbatim. Full IDs, native bindings, levels and exact parent links remain in the semantic ledger, source companions and manifest provenance in either mode. The subtitle “Documentary function hierarchy” labels this simplified presentation; the full claim limits remain in its companions and manifest. + +Edit the authoritative SysML and regenerate into another new directory. Do not patch exported SVG to change the engineering model. The output includes editable SVG text, an offline HTML reader, a semantic ledger, presentation parameters and a manifest. An actual graphical editor edit/save/reopen is a separate qualification. + +The complete [portable design specification](visual-design-spec.md) and `assets/diagram-style/design-tokens.json` preserve broader selected directions, conditional ranges and open choices. They are design inputs, not a runtime support matrix or the smaller `--profile` schema. + + +## Synthetic branching demonstration + +```sh +python3 scripts/sysml_present.py examples/sysml/views/field-service-demo.sysml --view FT_DEMO --labels examples/sysml/views/field-service-demo.labels.json --output work/station-tree --body white --target-width 1600 +``` + +This owned synthetic example declares one root function, three first-level functions and nine leaf functions. Its decomposition describes a service scope, without prescribing execution order, safety procedures, requirements or real equipment behavior. Its author supplied the readable labels explicitly. The native names, IDs, levels, bindings and parentage remain unchanged in `semantic-ledger.json` and exact SysML companions. The canonical `display-labels.json` companion and `presentation.json` record the separate wording. Unknown bindings, duplicate keys or elements, wrong views and unsupported glyphs are refused before output creation. Omitting a binding retains the exact native name; there is no inferred camel-case splitting. + +Nodes wrap within a measured text region and fit their own content plus padding. Branch subtrees are balanced horizontally; leaf siblings stack in compact local groups. Child counts are derived from the documentary tree and occupy a useful separate cell. `--show-ids` adds metadata after the display label. Neither framing nor color creates a model-owned part or compartment. + +| Design rule | Integrated behavior in this slice | Available or later work | +| --- | --- | --- | +| Square content-fit framing | Glyph-advance wrapping, bounded padding and height, no canvas-wide bars | Other notation-specific node shapes | +| Useful partitions and side keys | Derived child-count cell; optional ID/level cell; a single outer frame | Domain-owned compartments | +| Selected palette shade/tint roles | Orange/violet root; blue, tangerine and green shade/tint branches; readable ink labels | All eight families remain tokens; no universal status meanings | +| Inter and Space Grotesk | Embedded Inter title and Space Grotesk labels/counts, with pinned measured advances | Wider glyph qualification | +| Target-scale vector geometry | Editable text/SVG; declared display-pixel strokes; fresh vector raster export | Calibrated print and editor roundtrip | +| Line and hatch samples | Documentary decomposition routes only; shared trunks have explicit parents | Dashed relation semantics and typed materials/equipment textures | +| Drafted exterior illustrations | No invented physical geometry in this function view | Source-admitted physical glyph adapters | + +White, paper and faint-tint leaf bodies remain provisional choices; the demonstration requests white explicitly. The current selected compact-tree treatment restores blue tint/blue shade, tangerine tint/tangerine shade and green tint/green shade branches, with the original orange-on-violet root. Branch drops and local spines match the branch frame/accent, while the shared fan-out stays neutral. This display choice carries no universal engineering or status meaning. Texture is deliberately absent: this function model declares no material or equipment classes that could give a hatch meaning. + +`function_tree_roles` is an optional version-1 profile section containing a named pair catalog, `root_pair`, ordered `branch_pairs` and the neutral `shared_fanout` role. Each pair separately references `fill`, `frame` and `connector` as palette family/tone names. Matching frame/connector defaults preserve grouping without coupling their resolution. Same-family and cross-family pairs are both permitted when they pass contrast checks. Unknown references, unsupported tones and insufficient contrast are refused. Legacy version-1 custom profiles inherit the bundled explicit defaults. Resolved roles and per-node pair IDs accompany the output without changing the semantic ledger or geometry. + +| Selected local role | Header fill | Frame/accent | Connector default | +| --- | --- | --- | --- | +| Root: orange on violet | Violet tint `#e0dae6` | Tangerine shade `#894b25` | Tangerine shade `#894b25` | +| Blue branch | Blue tint `#d2dae6` | Blue shade `#27456f` | Blue shade `#27456f` | +| Tangerine branch | Tangerine tint `#eedcd1` | Tangerine shade `#894b25` | Tangerine shade `#894b25` | +| Green branch | Green tint `#d5e2da` | Green shade `#315e43` | Green shade `#315e43` | + +The previous cross-family branch treatment was declined and is not the default. Its brick/cream and green/cool pair definitions remain available as nondefault candidates; the rejected selection also remains in Git history. Their presence in the catalog establishes no visual acceptance. + +All text fields require at least 4.5:1 contrast. Header frames and connectors require at least 3:1 against their actual header or paper fields, and frames must also pass on every provisional leaf body. These arithmetic checks remain required for same-family and cross-family treatments. They supplement actual pixel review without selecting engineering meanings or guaranteeing visual acceptance. + +Text layout uses `fonts/metrics.json`, measured from the pinned WOFF2 faces with Canvas `measureText` at 1000px and tied to each face SHA-256. Advances scale to the selected font size; normal glyph shapes and spaces are retained. This particular layout disables kerning and ligatures consistently in measurement and SVG, and requests geometric-precision text rendering to retain fractional advances at small sizes. It applies no letter spacing, fitting transforms or character substitution. Soft line breaks concatenate to the exact authored string, including spaces. Actual browser glyph bounds, face identity and target-scale clearance still need checking for each qualified candidate; advance sums alone are not visual acceptance. + +## Selected direction and provisional parameters + +The bundled profile retains Graphite and signal palette03 and Inter titles with Space Grotesk body text. Its eight color families and four neutrals are reusable presentation values. They establish no global relation, status, risk, type or acceptance semantics. Orange on violet is an optional scoped presentation pairing, not a semantic classification. + +White, paper and faint-tint bodies remain explicit provisional alternatives. The proposed tint is 6% violet base over white, rounded per sRGB channel; it is not a ninth palette family. The function-tree defaults of 36-unit titles, 22-unit labels and 2 display-pixel lines are named slice-specific proposals. They do not override formal notation, source geometry or another view family's requirements. + +This slice refuses unsupported characters outside printable ASCII plus middle dot. The declared font files have broader ranges, but those ranges are not claimed as exercised glyph coverage. No forced tracking, text length, stretching or text-fitting transforms are applied. The target must retain essential label size; use a larger output/native scrolling for a wider model. + +## Reusable boundaries + +`verifier.visual_style` validates profile data, embeds exact font bytes/notices, computes opaque-field contrast and converts a proposed display stroke width to native coordinates. `verifier.visual_projection` consumes the published parsed inventory after existing checks, retains model identities and parentage, and emits compact square boxes, losslessly wrapped text and documentary parent routes. A shared route is drawn once only for children with the same explicit parent; unrelated crossings never imply a junction. The common fan-out trunk is neutral, with scoped colored branch drops. + +A narrow first slice does not implement physical glyph semantics, RF/data/material hatches, circuit connectivity or formal port notation. Source-qualified line patterns, contact side/order, arrows, texture classes and values stay with their domain adapters. The broader draft-profile and crispness studies are references for later qualification, not permission to infer those relationships from styling. No private worked reference art is shipped as a production renderer. + +## Checks and limits + +Run `python3 scripts/check.py --require-sysml` with the existing pinned parser installed and Java21 available. New focused suites cover malformed profiles, invalid native bindings, hierarchy defects, source preservation, deterministic output and edit/regeneration. + +Contrast uses opaque sRGB fields and unrounded ratios; this slice requires at least4.5:1 for its text fields. This is a bounded numerical check, not a complete accessibility or readability verdict. [W3C contrast guidance](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) explains why actual size and antialiasing also matter. Inspect native and target-width pixels, loaded font identities, clipping, overlap and endpoints in the actual browser. Display widths are CSS pixels, not calibrated print dimensions. + +The output manifest identifies supplied model bytes, profile, producer and font assets. Hashes identify inputs, not genuine engineering acceptance, authenticated decisions or a protected runner. Parse success and documentary structure do not establish semantic truth or completeness. Parent visual acceptance, installed-client activation, other diagram families, calibrated print and reader-comprehension claims remain separate. + +## Fonts and rights + +The four unchanged Latin WOFF2 faces come from `@fontsource/inter@5.3.0` and `@fontsource/space-grotesk@5.3.0`. Exact files, declared ranges, hashes and source-package identities are in `assets/diagram-style/fonts/manifest.json`. Original OFL notices are retained alongside the files and embedded in standalone font CSS. No font modifications or independent binary-revision decoding are claimed. The repository MIT license does not replace these notices. diff --git a/examples/sysml/views/field-service-demo.labels.json b/examples/sysml/views/field-service-demo.labels.json new file mode 100644 index 0000000..79d78c5 --- /dev/null +++ b/examples/sysml/views/field-service-demo.labels.json @@ -0,0 +1,59 @@ +{ + "version": 1, + "view": "FT_DEMO", + "title": "Maintain the inspection station", + "labels": [ + { + "element": "FieldServiceDemo::MaintainStation", + "text": "Maintain the inspection station" + }, + { + "element": "FieldServiceDemo::PrepareServiceVisit", + "text": "Prepare the service visit and station access" + }, + { + "element": "FieldServiceDemo::ReviewServiceRequest", + "text": "Review the reported symptoms and service request" + }, + { + "element": "FieldServiceDemo::ConfirmAccessReadiness", + "text": "Confirm station access and workspace readiness" + }, + { + "element": "FieldServiceDemo::GatherServiceResources", + "text": "Gather reference information and service tools" + }, + { + "element": "FieldServiceDemo::InspectAndRestoreStation", + "text": "Inspect the station and restore its working condition" + }, + { + "element": "FieldServiceDemo::InspectStationCondition", + "text": "Inspect station components and record observations" + }, + { + "element": "FieldServiceDemo::InvestigateReportedIssue", + "text": "Investigate the reported issue using observed evidence" + }, + { + "element": "FieldServiceDemo::RestoreAffectedComponents", + "text": "Restore affected components within the service scope" + }, + { + "element": "FieldServiceDemo::ReturnStationAndReport", + "text": "Return the station and communicate service findings" + }, + { + "element": "FieldServiceDemo::ReviewPostServiceCondition", + "text": "Review the station condition after the service work" + }, + { + "element": "FieldServiceDemo::ReturnStationToCustodian", + "text": "Return the station and explain any remaining concerns" + }, + { + "element": "FieldServiceDemo::DocumentServiceFindings", + "text": "Document completed work and unresolved observations" + } + ] +} diff --git a/examples/sysml/views/field-service-demo.sysml b/examples/sysml/views/field-service-demo.sysml new file mode 100644 index 0000000..418792f --- /dev/null +++ b/examples/sysml/views/field-service-demo.sysml @@ -0,0 +1,109 @@ +package FieldServiceDemo { + doc /* Original synthetic station-maintenance decomposition for presentation testing. + Documentary parentage only: no requirements, acceptance status, or execution order. */ + action def MaintainStation; + action def PrepareServiceVisit; + action def ReviewServiceRequest; + action def ConfirmAccessReadiness; + action def GatherServiceResources; + action def InspectAndRestoreStation; + action def InspectStationCondition; + action def InvestigateReportedIssue; + action def RestoreAffectedComponents; + action def ReturnStationAndReport; + action def ReviewPostServiceCondition; + action def ReturnStationToCustodian; + action def DocumentServiceFindings; + + doc cseViews /* + CSE-VIEWS/1.0 +{ + "views": [ + { + "id": "FT_DEMO", + "kind": "function_tree", + "nodes": [ + { + "id": "F0", + "element": "FieldServiceDemo::MaintainStation", + "level": 0, + "parent": null + }, + { + "id": "F1", + "element": "FieldServiceDemo::PrepareServiceVisit", + "level": 1, + "parent": "F0" + }, + { + "id": "F11", + "element": "FieldServiceDemo::ReviewServiceRequest", + "level": 2, + "parent": "F1" + }, + { + "id": "F12", + "element": "FieldServiceDemo::ConfirmAccessReadiness", + "level": 2, + "parent": "F1" + }, + { + "id": "F13", + "element": "FieldServiceDemo::GatherServiceResources", + "level": 2, + "parent": "F1" + }, + { + "id": "F2", + "element": "FieldServiceDemo::InspectAndRestoreStation", + "level": 1, + "parent": "F0" + }, + { + "id": "F21", + "element": "FieldServiceDemo::InspectStationCondition", + "level": 2, + "parent": "F2" + }, + { + "id": "F22", + "element": "FieldServiceDemo::InvestigateReportedIssue", + "level": 2, + "parent": "F2" + }, + { + "id": "F23", + "element": "FieldServiceDemo::RestoreAffectedComponents", + "level": 2, + "parent": "F2" + }, + { + "id": "F3", + "element": "FieldServiceDemo::ReturnStationAndReport", + "level": 1, + "parent": "F0" + }, + { + "id": "F31", + "element": "FieldServiceDemo::ReviewPostServiceCondition", + "level": 2, + "parent": "F3" + }, + { + "id": "F32", + "element": "FieldServiceDemo::ReturnStationToCustodian", + "level": 2, + "parent": "F3" + }, + { + "id": "F33", + "element": "FieldServiceDemo::DocumentServiceFindings", + "level": 2, + "parent": "F3" + } + ] + } + ] +} + */ +} diff --git a/scripts/check.py b/scripts/check.py index 884b037..8f221b8 100644 --- a/scripts/check.py +++ b/scripts/check.py @@ -43,7 +43,7 @@ def main(): suites = ("test_develop_conops.py", "test_scenario_split.py", "test_concept.py", "test_verifier.py", "test_language.py", "test_language_oracle.py", "test_influence.py", "test_events.py", "test_public_language_cases.py", "test_sysml.py", "test_sysml_views.py", - "test_sysml_public.py", "test_skill_runtime.py", "test_engineering_views.py") + "test_sysml_public.py", "test_skill_runtime.py", "test_engineering_views.py", "test_visual_style.py", "test_visual_projection.py", "test_visual_labels.py") for suite in suites: result = subprocess.run([sys.executable, "-B", str(ROOT / "tests" / suite)], cwd=ROOT, env=environment, text=True, diff --git a/scripts/sysml_present.py b/scripts/sysml_present.py new file mode 100644 index 0000000..c22d106 --- /dev/null +++ b/scripts/sysml_present.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Create an offline documentary function-tree presentation from native SysML.""" +import argparse +import json +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from verifier.visual_projection import PresentationError, write_presentation +from verifier.visual_style import load_profile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('model', nargs='+') + parser.add_argument('--view', required=True) + parser.add_argument('--output', required=True, help='new output directory (must not exist)') + parser.add_argument('--profile', help='validated presentation style JSON') + parser.add_argument('--body', choices=('paper', 'white', 'tint'), default='paper') + parser.add_argument('--target-width', type=int, default=1200) + parser.add_argument('--show-ids', action='store_true', help='show view ID and node IDs/levels after native names (hidden by default)') + parser.add_argument('--labels', help='explicit native-element-bound presentation labels JSON') + args = parser.parse_args() + try: + report = write_presentation(args.model, args.output, view_id=args.view, + profile=load_profile(args.profile), body=args.body, + target_width=args.target_width, show_ids=args.show_ids, labels=args.labels) + except PresentationError as exc: + report = exc.report + except (OSError, ValueError, KeyError) as exc: + report = {'status': 'error', 'findings': [{'code': 'presentation', 'detail': str(exc)}]} + print(json.dumps(report, indent=2, sort_keys=True, ensure_ascii=False)) + return 0 if report['status'] == 'pass' else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_visual_labels.py b/tests/test_visual_labels.py new file mode 100644 index 0000000..e24473b --- /dev/null +++ b/tests/test_visual_labels.py @@ -0,0 +1,129 @@ +"""Presentation wording remains explicit, native-bound, and separate from models.""" +from copy import deepcopy +import json +from pathlib import Path +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from verifier.visual_labels import apply_labels, load_labels +from verifier.sysml import CLASSES, JAR, check_sysml + +MODEL = ROOT / 'examples/sysml/views/field-service-demo.sysml' +LABELS = MODEL.with_suffix('.labels.json') + + +def projection(): + return {'id': 'FT', 'kind': 'function_tree', 'nodes': [ + {'id': 'F0', 'element': 'Example::MaintainStation', 'text': 'MaintainStation', 'level': 0, 'parent': None}, + {'id': 'F1', 'element': 'Example::ReviewServiceRequest', 'text': 'ReviewServiceRequest', 'level': 1, 'parent': 'F0'}]} + + +def record(): + return {'version': 1, 'view': 'FT', 'title': 'Maintain the station', + 'labels': [{'element': 'Example::MaintainStation', 'text': 'Maintain the station'}]} + + +class VisualLabelTests(unittest.TestCase): + def test_native_default_and_partial_explicit_wording_preserve_projection(self): + model = projection() + prior = deepcopy(model) + self.assertIsNone(load_labels()) + default = apply_labels(model) + self.assertEqual(default, {'title': 'Function decomposition', 'labels': { + 'F0': 'MaintainStation', 'F1': 'ReviewServiceRequest'}, 'bindings': []}) + explicit = record() + saved = deepcopy(explicit) + result = apply_labels(model, explicit) + self.assertEqual(result['title'], 'Maintain the station') + self.assertEqual(result['labels'], {'F0': 'Maintain the station', 'F1': 'ReviewServiceRequest'}) + self.assertEqual(result['bindings'], explicit['labels']) + self.assertEqual(model, prior) + self.assertEqual(explicit, saved) + result['bindings'][0]['text'] = 'Changed output' + self.assertEqual(explicit, saved) + self.assertEqual(model, prior) + + def test_load_is_strict_and_rejects_unsupported_or_blank_wording(self): + for change in ( + {'version': True}, {'version': 2}, {'view': 'wrong view'}, {'title': ''}, {'title': ' '}, + {'title': 'Unsupported \u2603'}, {'title': 'Line\nbreak'}, {'labels': {}}, + {'labels': [{'element': 'Example::MaintainStation', 'text': ' '}]}, + {'labels': [{'element': 'Example::MaintainStation', 'text': 'New text', 'status': 'accepted'}]}, + {'labels': [{'element': '', 'text': 'New text'}]}, {'authority': 'accepted'}, + ): + with self.subTest(change=change): + candidate = record() + candidate.update(change) + with self.assertRaises(ValueError): load_labels(candidate) + for field in record(): + candidate = record() + candidate.pop(field) + with self.subTest(missing=field), self.assertRaises(ValueError): load_labels(candidate) + candidate = record() + candidate['title'] = 'Inspect · restore' + self.assertEqual(load_labels(candidate)['title'], 'Inspect · restore') + + def test_native_bindings_cannot_target_other_views_or_unknown_or_repeated_elements(self): + model = projection() + other = record() + other['view'] = 'Other' + with self.assertRaisesRegex(ValueError, 'different view'): apply_labels(model, other) + unknown = record() + unknown['labels'][0]['element'] = 'Example::Absent' + with self.assertRaisesRegex(ValueError, 'outside'): apply_labels(model, unknown) + repeated = record() + repeated['labels'].append(deepcopy(repeated['labels'][0])) + with self.assertRaisesRegex(ValueError, 'Repeated'): apply_labels(model, repeated) + for field in ('id', 'element'): + ambiguous = projection() + ambiguous['nodes'][1][field] = ambiguous['nodes'][0][field] + with self.subTest(field=field), self.assertRaisesRegex(ValueError, 'unique'): + apply_labels(ambiguous, record()) + + def test_file_loading_duplicate_keys_and_no_source_code_evaluation(self): + (ROOT / '.tmp').mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as temp: + path = Path(temp) / 'labels.json' + path.write_text(json.dumps(record())) + self.assertEqual(load_labels(path), record()) + for content in ( + '{"version":1,"version":1,"view":"FT","title":"Title","labels":[]}', + '{"version":1,"view":"FT","title":"Title","labels":[{"element":"X","text":"A","text":"B"}]}', + '{"version":NaN,"view":"FT","title":"Title","labels":[]}', + ): + path.write_text(content) + with self.subTest(content=content), self.assertRaises(ValueError): load_labels(path) + candidate = record() + candidate['labels'][0]['text'] = "__import__('os').system('false')" + path.write_text(json.dumps(candidate)) + self.assertEqual(apply_labels(projection(), path)['labels']['F0'], candidate['labels'][0]['text']) + + +@unittest.skipUnless(JAR.is_file() and (CLASSES / 'SysmlBridge.class').is_file(), 'Pinned native parser unavailable') +class NativeLabelFixtureTests(unittest.TestCase): + def test_original_demo_binds_thirteen_native_actions_and_exact_three_level_tree(self): + report = check_sysml(MODEL, syntax_only=True) + self.assertEqual(report['status'], 'pass', report) + inventory = report['inventory'] + from verifier.visual_projection import project_function_tree + model = project_function_tree(inventory, 'FT_DEMO') + prior = deepcopy(model) + self.assertEqual(len(model['nodes']), 13) + self.assertEqual([sum(n['level'] == level for n in model['nodes']) for level in (0, 1, 2)], [1, 3, 9]) + for branch in (n for n in model['nodes'] if n['level'] == 1): + self.assertEqual(sum(n['parent'] == branch['id'] for n in model['nodes']), 3) + labels = load_labels(LABELS) + result = apply_labels(model, labels) + self.assertEqual(len(result['bindings']), 13) + self.assertEqual(set(result['labels']), {n['id'] for n in model['nodes']}) + self.assertEqual(result['title'], 'Maintain the inspection station') + self.assertEqual(apply_labels(model)['labels']['F0'], 'MaintainStation') + self.assertEqual(model, prior) + self.assertTrue(all(' ' in text for text in result['labels'].values())) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_visual_projection.py b/tests/test_visual_projection.py new file mode 100644 index 0000000..7c17363 --- /dev/null +++ b/tests/test_visual_projection.py @@ -0,0 +1,422 @@ +"""Synthetic documentary projection, refusal, offline and native regeneration tests.""" +from copy import deepcopy +from hashlib import sha256 +import json +import re +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from verifier.visual_style import load_profile +from verifier.sysml import CLASSES, JAR +from verifier.visual_projection import PresentationError, project_function_tree, render_function_tree, write_presentation + +MODEL = ROOT / 'examples/sysml/views/sample-handling.sysml' + + +def fixture(): + nodes = [{'id': 'F0', 'element': 'Example::Process', 'level': 0, 'parent': None}, + {'id': 'F1', 'element': 'Example::Read', 'level': 1, 'parent': 'F0'}, + {'id': 'F2', 'element': 'Example::Store', 'level': 1, 'parent': 'F0'}] + inventory = {'actions': [{'qualified_name': n['element'], 'name': name} for n, name in + zip(nodes, ['Process item', 'Read & "quote"', 'Store item'])], 'parts': [], + 'documents': [{'name': 'cseViews', 'owner_kind': 'Package', 'body': ''}]} + return nodes, inventory + + +def project(nodes, inventory): + inventory['documents'][0]['body'] = 'CSE-VIEWS/1.0\n' + json.dumps({'views': [{'id': 'FT', 'kind': 'function_tree', 'nodes': nodes}]}) + return project_function_tree(inventory, 'FT') + + +class ProjectionTests(unittest.TestCase): + def test_exact_identity_text_xml_and_determinism(self): + nodes, inventory = fixture() + expected = deepcopy(nodes) + projection = project(nodes, inventory) + self.assertEqual(nodes, expected) + for n, original in zip(projection['nodes'], expected): + self.assertEqual({k: n[k] for k in original}, original) + svg, ledger = render_function_tree(projection) + self.assertEqual((svg, ledger), render_function_tree(projection)) + root = ET.fromstring(svg) + texts = [''.join(item.itertext()) for item in root.iter('{http://www.w3.org/2000/svg}text')] + self.assertIn('Read & "quote"', texts) + self.assertNotIn('', svg) + self.assertNotIn('marker-end', svg) + groups = [item for item in root.iter('{http://www.w3.org/2000/svg}g')] + self.assertEqual([g.attrib['data-element'] for g in groups], [n['element'] for n in nodes]) + self.assertEqual(len(root.findall("{http://www.w3.org/2000/svg}path[@data-child]")), len(nodes)-1) + for a, b in zip(ledger['geometry'], ledger['geometry'][1:]): + self.assertTrue(a['y'] + a['height'] <= b['y'] or a['x'] + a['width'] <= b['x'] or b['x'] + b['width'] <= a['x']) + + def test_parsed_svg_preserves_title_body_roles_and_square_containers(self): + nodes, inventory = fixture() + root = ET.fromstring(render_function_tree(project(nodes, inventory), show_ids=True)[0]) + namespace = {'svg': 'http://www.w3.org/2000/svg'} + title = root.find("svg:text[@data-role='title']",namespace) + annotation = root.find("svg:text[@data-role='subtitle']",namespace) + self.assertEqual(title.attrib['font-family'], 'Inter') + self.assertEqual(annotation.attrib['font-family'], 'Space Grotesk') + groups = root.findall('svg:g', namespace) + self.assertEqual(len(groups), len(nodes)) + for group in groups: + native_label = group.find("svg:text[@data-role='label']", namespace) + metadata = group.find("svg:text[@data-role='metadata']", namespace) + self.assertIn('level', metadata.text) + self.assertEqual(metadata.attrib['font-family'], 'Space Grotesk') + self.assertEqual(native_label.attrib['font-family'], 'Space Grotesk') + rectangle = group.find('svg:rect', namespace) + self.assertNotIn('rx', rectangle.attrib) + self.assertNotIn('ry', rectangle.attrib) + + def test_content_fit_wrapping_and_attached_documentary_routes(self): + nodes, inventory = fixture() + projection = project(nodes, inventory) + ns = {'svg':'http://www.w3.org/2000/svg'} + for size in (20, 22, 24): + profile = load_profile() + profile['numeric_proposal']['body_size'] = size + svg, ledger = render_function_tree(projection, profile=profile, show_ids=True, target_width=1600) + tree = ET.fromstring(svg) + boxes = {g.attrib['data-id']:g.find('svg:rect',ns).attrib for g in tree.findall('svg:g',ns)} + self.assertEqual({(e['parent'],e['child']) for e in ledger['edges']}, {('F0','F1'),('F0','F2')}) + for group in tree.findall('svg:g',ns): + self.assertLess(float(group.find('svg:rect',ns).attrib['width']), 450) + label=group.find("svg:text[@data-role='label']",ns) + self.assertEqual(float(label.attrib['font-size']),size) + for edge in ledger['edges']: + parent,child=boxes[edge['parent']],boxes[edge['child']] + start,end=edge['points'][0],edge['points'][-1] + self.assertEqual(start[1],float(parent['y'])+float(parent['height'])) + self.assertEqual(end[0],float(child['x'])) + self.assertEqual(end[1],float(child['y'])+float(child['height'])/2) + projection['nodes'][1]['text']='A long native engineering name ' * 10 + before=deepcopy(projection) + tree=ET.fromstring(render_function_tree(projection,target_width=1600)[0]) + label=tree.findall('svg:g',ns)[1].find("svg:text[@data-role='label']",ns) + self.assertEqual(''.join(label.itertext()),projection['nodes'][1]['text']) + self.assertGreater(len(label.findall('svg:tspan',ns)),2) + self.assertEqual(projection,before) + + def test_wide_view_ids_wrap_without_truncation_and_small_targets_refuse(self): + nodes,inventory=fixture() + projection=project(nodes,inventory) + projection['id']='W'*64 + svg,ledger=render_function_tree(projection,show_ids=True,target_width=1600) + tree=ET.fromstring(svg) + title=tree.find('{http://www.w3.org/2000/svg}text') + self.assertEqual(''.join(title.itertext()),'W'*64+' - Function decomposition') + self.assertGreater(len(title),1) + for target in (320,400): + with self.assertRaisesRegex(ValueError,'Target too small'): + render_function_tree(projection,show_ids=True,target_width=target) + + def test_default_names_and_opt_in_metadata_preserve_semantics(self): + nodes, inventory = fixture() + # A legitimate native engineering identifier must not be stripped. + inventory['actions'][0]['name'] = 'F0 Pump' + projection = project(nodes, inventory) + before = deepcopy(projection) + ns = {'svg': 'http://www.w3.org/2000/svg'} + for show_ids in (False, True): + with self.subTest(show_ids=show_ids): + svg, ledger = render_function_tree(projection, show_ids=show_ids) + tree = ET.fromstring(svg) + title=tree.find("svg:text[@data-role='title']",ns) + subtitle=tree.find("svg:text[@data-role='subtitle']",ns) + self.assertEqual(''.join(title.itertext()), ('FT - ' if show_ids else '') + 'Function decomposition') + self.assertEqual(subtitle.text, 'Documentary function hierarchy') + for group, node in zip(tree.findall('svg:g', ns), projection['nodes']): + labels = group.findall("svg:text[@data-role='label']", ns) + self.assertEqual(''.join(labels[0].itertext()), node['text']) + metadata=group.findall("svg:text[@data-role='metadata']",ns) + self.assertEqual(bool(metadata),show_ids) + if show_ids: + self.assertEqual(''.join(t.text or '' for t in metadata), f"{node['id']} · level {node['level']}") + self.assertEqual(group.attrib['data-id'], node['id']) + self.assertEqual(group.attrib['data-level'], str(node['level'])) + self.assertEqual(group.attrib['data-element'], node['element']) + self.assertEqual(ledger['show_ids'], show_ids) + self.assertIn('no behavioral or engineering acceptance claim', ledger['claim_scope']) + self.assertEqual(projection, before) + for invalid in ('false', 1, None): + with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, 'boolean'): + render_function_tree(projection, show_ids=invalid) + + def test_invalid_structure_bindings_and_requested_views_refused(self): + for defect in ('cycle', 'orphan', 'level', 'binding', 'duplicate', 'missing', 'kind'): + nodes, inventory = fixture() + if defect == 'cycle': + nodes[1]['parent'] = 'F2'; nodes[2]['parent'] = 'F1' + elif defect == 'orphan': nodes[1]['parent'] = 'MISSING' + elif defect == 'level': nodes[1]['level'] = 4 + elif defect == 'binding': nodes[1]['element'] = 'Absent' + elif defect == 'duplicate': nodes.append(deepcopy(nodes[1])) + with self.subTest(defect=defect): + if defect in ('missing', 'kind'): + project(nodes, inventory) + if defect == 'missing': inventory['documents'] = [] + else: inventory['documents'][0]['body'] = 'CSE-VIEWS/1.0\n' + json.dumps({'views': [{'id': 'FT', 'kind': 'unrecognized'}]}) + with self.assertRaises(PresentationError): project_function_tree(inventory, 'FT') + else: + with self.assertRaises(PresentationError): project(nodes, inventory) + + def test_profile_pairs_change_only_colors_without_model_or_geometry_changes(self): + nodes,inventory=fixture();projection=project(nodes,inventory);saved=deepcopy(projection) + first,ledger=render_function_tree(projection,body='white') + p=load_profile();pair=p['function_tree_roles']['pairs']['orange_on_violet'] + pair['fill']='cyan.tint';pair['connector']='blue.shade' + p['function_tree_roles']['pairs']['blue_on_blue']['connector']='red.shade' + second,changed=render_function_tree(projection,body='white',profile=p) + self.assertNotEqual(first,second) + def non_color(svg): + tree=ET.fromstring(svg) + for item in tree.iter(): + for key in ('fill','stroke'):item.attrib.pop(key,None) + return ET.tostring(tree) + self.assertEqual(non_color(first),non_color(second)) + self.assertEqual(ledger['geometry'],changed['geometry']) + self.assertEqual(ledger['edges'],changed['edges']) + self.assertEqual(projection,saved) + root=ET.fromstring(second);ns={'svg':'http://www.w3.org/2000/svg'} + frame=root.find("svg:g[@data-id='F0']/svg:rect",ns) + self.assertEqual(frame.attrib['fill'],'#d4e3e7') + self.assertEqual(frame.attrib['stroke'],'#894b25') + drop=root.find("svg:path[@data-child='F1']",ns) + self.assertEqual(drop.attrib['stroke'],'#7f362f') + + def test_shared_parent_trunk_is_single_neutral_route_with_explicit_child_drops(self): + nodes,inventory=fixture();projection=project(nodes,inventory) + svg,ledger=render_function_tree(projection) + ns={'svg':'http://www.w3.org/2000/svg'};tree=ET.fromstring(svg) + trunks=tree.findall("svg:path[@data-shared-parent='F0']",ns) + self.assertEqual(len(trunks),1) + self.assertEqual(trunks[0].attrib['stroke'],load_profile()['palette']['ink']) + drops=tree.findall('svg:path[@data-child]',ns) + self.assertEqual({p.attrib['data-child'] for p in drops},{'F1','F2'}) + self.assertTrue(all(p.attrib['data-parent']=='F0' for p in drops)) + self.assertEqual({e['child'] for e in ledger['edges']},{'F1','F2'}) + self.assertEqual(len(ledger['shared_routes']),1) + + def test_render_revalidates_direct_projection_and_bounds(self): + nodes, inventory = fixture() + projection = project(nodes, inventory) + projection['nodes'][1]['parent'] = 'Absent' + with self.assertRaises(PresentationError): render_function_tree(projection) + projection = project(nodes, inventory) + for width in (0, float('nan'), True, 9000): + with self.subTest(width=width), self.assertRaises(ValueError): + render_function_tree(projection, target_width=width) + projection['nodes'][0]['text'] = 'Unsupported \u2603' + with self.assertRaises(ValueError): render_function_tree(projection) + + def test_failed_native_acquisition_creates_no_output(self): + (ROOT / '.tmp').mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as temp: + output = Path(temp) / 'output' + for status in ('fail', 'unknown', 'error', 'unsupported'): + report = {'status': status, 'findings': [{'code': 'input_or_parser', 'detail': 'SysML pilot not installed'}]} + with patch('verifier.visual_projection.check_sysml', return_value=report): + self.assertEqual(write_presentation([MODEL], output, view_id='FT'), report) + self.assertFalse(output.exists()) + + +@unittest.skipUnless(JAR.is_file() and (CLASSES / 'SysmlBridge.class').is_file(), 'Pinned native parser unavailable') +class NativePresentationTests(unittest.TestCase): + def test_native_offline_hashes_sources_exclusive_output_and_regeneration(self): + (ROOT / '.tmp').mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as temp: + temp = Path(temp) + source = temp / 'model.sysml' + source.write_bytes(MODEL.read_bytes()) + before = source.read_bytes() + report = write_presentation(source, temp / 'one', view_id='FT') + self.assertEqual(report['status'], 'pass', report) + self.assertEqual(source.read_bytes(), before) + write_presentation(source, temp / 'two', view_id='FT') + for file in (temp / 'one').rglob('*'): + if file.is_file(): self.assertEqual(file.read_bytes(), (temp / 'two' / file.relative_to(temp / 'one')).read_bytes()) + manifest = json.loads((temp / 'one/manifest.json').read_text()) + for name, digest in manifest['files'].items(): + self.assertEqual(sha256((temp / 'one' / name).read_bytes()).hexdigest(), digest) + self.assertEqual((temp / 'one/sources/model-1.sysml').read_bytes(), before) + svg = (temp / 'one/view.svg').read_text() + html = (temp / 'one/index.html').read_text() + self.assertIn(svg, html) + self.assertIn('data:font/', html) + self.assertNotIn('=3 for k,v in pair['contrast'].items() if k!='text')) + p['function_tree_roles']['pairs']['orange_on_violet']['connector']='blue.shade' + changed=function_tree_roles(p)['pairs']['orange_on_violet'] + self.assertEqual(changed['connector_color'],'#27456f') + self.assertEqual(changed['frame_color'],'#894b25') + self.assertEqual(changed['fill_color'],'#e0dae6') + legacy=deepcopy(saved);legacy.pop('function_tree_roles') + self.assertEqual(load_profile(legacy)['function_tree_roles'],saved['function_tree_roles']) + tokens=json.loads((ROOT/'assets/diagram-style/design-tokens.json').read_text()) + self.assertEqual(tokens['function_tree_implementation']['roles'],saved['function_tree_roles']) + self.assertEqual(load_profile(),saved) + + def test_tree_role_refusals_prevent_unknown_or_low_contrast_pairs(self): + for defect in ('unknown_ref','unknown_pair','header_contrast','role_tone','css','status','connector_contrast'): + p=load_profile();roles=p['function_tree_roles'];pair=roles['pairs']['orange_on_violet'] + if defect=='unknown_ref':pair['fill']='missing.tint' + elif defect=='unknown_pair':roles['root_pair']='missing' + elif defect=='header_contrast':p['palette']['families']['blue']['tint']=p['palette']['families']['blue']['shade'] + elif defect=='role_tone':pair['connector']='blue.tint' + elif defect=='css':pair['style']='anything' + elif defect=='status':roles['status']='universally_accepted' + else:p['palette']['families']['green']['shade']='#eeeeea' + with self.subTest(defect=defect),self.assertRaises(ValueError):load_profile(p) + + def test_absolute_contrast_oracles(self): + self.assertAlmostEqual(contrast("#000000", "#ffffff"), 21) + self.assertEqual(contrast("#123456", "#123456"), 1) + with self.assertRaises(ValueError): contrast("transparent", "#ffffff") + + def test_effective_stroke_and_small_target_refusal(self): + s = presentation_style(target_width=900, canvas_width=1200) + self.assertAlmostEqual(s["stroke_width"] * 900 / 1200, 2) + with self.assertRaises(ValueError): presentation_style(target_width=400, canvas_width=1200) + for value in (True, float("inf"), float("nan"), 0): + with self.subTest(value=value), self.assertRaises(ValueError): + presentation_style(target_width=value) + with self.assertRaises(ValueError): presentation_style(body="approved-default") + + def test_profile_refuses_unknown_or_semantic_changes(self): + for mutate in ("unknown", "numeric", "status", "color", "contrast"): + p = deepcopy(load_profile()) + if mutate == "unknown": p["execute"] = "anything" + elif mutate == "numeric": p["numeric_proposal"]["body_size"] = True + elif mutate == "status": p["body_variants"]["status"] = "accepted" + elif mutate == "color": p["palette"]["ink"] = "url(file:///private)" + else: p["palette"]["ink"] = "#eeeeea" + with tempfile.TemporaryDirectory(dir=ROOT / ".tmp") as folder: + path = Path(folder) / "profile.json"; path.write_text(json.dumps(p)) + with self.assertRaises(ValueError): load_profile(path) + + def test_nondefault_profile_dict_and_file_share_strict_validation(self): + p = load_profile() + p["numeric_proposal"]["body_size"] = 23 + expected = deepcopy(p) + with tempfile.TemporaryDirectory(dir=ROOT / ".tmp") as folder: + path = Path(folder) / "profile.json" + path.write_text(json.dumps(p)) + self.assertEqual(presentation_style(load_profile(path))["body_size"], 23) + self.assertEqual(presentation_style(path)["body_size"], 23) + self.assertEqual(p, expected) + p["body_variants"]["status"] = "accepted" + with self.assertRaises(ValueError): presentation_style(p) + + def test_measured_wrapping_preserves_text_and_tracks_actual_face(self): + self.assertGreater(text_width('WWW',22),text_width('iii',22)*2) + self.assertAlmostEqual(text_width('ABC',22),text_width('A',22)+text_width('B',22)+text_width('C',22)) + for text in ('A long engineering name with all spaces retained', 'W'*64, ' exact spaces '): + lines=wrap_text(text,150,22) + self.assertEqual(''.join(lines),text) + self.assertTrue(all(text_width(line,22)<=150 for line in lines)) + with self.assertRaises(ValueError):wrap_text('W',1,22) + + def test_font_bytes_and_explicit_coverage(self): + css = font_css() + self.assertEqual(css.count("@font-face"), 4) + self.assertEqual(css.count("data:font/woff2;base64,"), 4) + self.assertNotIn("url(http", css) + self.assertIn("SIL OPEN FONT LICENSE", css) + require_supported_text("ProcessSample · F0") + for value in ("theta \u03b8", "\n", ""): + with self.assertRaises(ValueError): require_supported_text(value) + +if __name__ == "__main__": + (ROOT / ".tmp").mkdir(exist_ok=True) + unittest.main() diff --git a/verifier/visual_labels.py b/verifier/visual_labels.py new file mode 100644 index 0000000..c82b521 --- /dev/null +++ b/verifier/visual_labels.py @@ -0,0 +1,71 @@ +"""Explicit, native-element-bound presentation wording; never model renaming.""" +from copy import deepcopy +import json +from pathlib import Path + +from .engineering_views import ID +from .visual_style import require_supported_text + + +def _unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"Duplicate label JSON key: {key}") + result[key] = value + return result + + +def load_labels(value=None): + """Validate a label dictionary or JSON file; None selects exact native names.""" + if value is None: + return None + if isinstance(value, (str, Path)): + value = json.loads(Path(value).read_text(), object_pairs_hook=_unique, + parse_constant=lambda text: (_ for _ in ()).throw(ValueError(f"Nonfinite {text}"))) + if not isinstance(value, dict) or set(value) != {"version", "view", "title", "labels"}: + raise ValueError("Labels need exactly version, view, title and labels") + if type(value['version']) is not int or value['version'] != 1: + raise ValueError("Expected presentation labels version 1") + if not isinstance(value['view'], str) or not ID.fullmatch(value['view']): + raise ValueError("Labels need a supported view ID") + require_supported_text(value['title']) + if not value['title'].strip() or not isinstance(value['labels'], list): + raise ValueError("Labels need a nonempty title and a labels list") + seen = set() + for label in value['labels']: + if not isinstance(label, dict) or set(label) != {'element', 'text'}: + raise ValueError("Each display label needs exactly element and text") + for field in ('element', 'text'): + require_supported_text(label[field]) + if not label[field].strip(): + raise ValueError(f"Display label {field} cannot be blank") + if label['element'] in seen: + raise ValueError("Repeated display label native element: " + label['element']) + seen.add(label['element']) + return deepcopy(value) + + +def apply_labels(projection, value=None): + """Return separate wording, preserving the checked projection byte for byte.""" + record = load_labels(value) + if record is not None and record['view'] != projection['id']: + raise ValueError("Display labels select a different view") + nodes = projection['nodes'] + by_element, node_ids, labels = {}, set(), {} + for node in nodes: + element, node_id = node['element'], node['id'] + if element in by_element or node_id in node_ids: + raise ValueError("Presentation requires unique native bindings and node IDs") + require_supported_text(element) + require_supported_text(node['text']) + by_element[element] = node_id + node_ids.add(node_id) + labels[node_id] = node['text'] + bindings = [] if record is None else record['labels'] + for binding in bindings: + if binding['element'] not in by_element: + raise ValueError("Display label binds outside the selected native view: " + binding['element']) + labels[by_element[binding['element']]] = binding['text'] + return {'title': 'Function decomposition' if record is None else record['title'], + 'labels': labels, 'bindings': deepcopy(bindings)} diff --git a/verifier/visual_projection.py b/verifier/visual_projection.py new file mode 100644 index 0000000..ef6a37f --- /dev/null +++ b/verifier/visual_projection.py @@ -0,0 +1,295 @@ +"""Bounded, offline documentary presentations of native CSE-VIEWS function trees.""" + +from copy import deepcopy +from hashlib import sha256 +from html import escape +import json +import math +from pathlib import Path +import shutil + +from .engineering_views import check_views +from .sysml import check_sysml +from .visual_style import ASSETS, load_profile, presentation_style, require_supported_text, text_width, wrap_text, contrast, function_tree_roles +from .visual_labels import apply_labels, load_labels + +VERSION = "cse-visual-presentation-1.0" +CLAIM = "Simplified model-derived presentation: documentary decomposition; no behavioral or engineering acceptance claim." + + +class PresentationError(ValueError): + """A refused presentation, retaining the authoritative checker diagnostics.""" + + def __init__(self, report): + self.report = report + super().__init__(json.dumps(report, sort_keys=True, ensure_ascii=False)) + + +def _json(value): + return json.dumps(value, sort_keys=True, indent=2, ensure_ascii=False, allow_nan=False) + "\n" + + +def _digest(raw): + return sha256(raw).hexdigest() + + +def project_function_tree(inventory, view_id): + """Check native bindings first, then copy exact selected nodes and native names.""" + checks = check_views(inventory, {"version": 1, "views": [{"id": view_id, "kind": "function_tree"}]}) + if checks["status"] != "pass": + raise PresentationError(checks) + doc = next(doc for doc in inventory["documents"] if doc.get("name") == "cseViews") + data = json.loads("\n".join(doc["body"].strip().splitlines()[1:])) + view = next(view for view in data["views"] if view["id"] == view_id) + actions = {item["qualified_name"]: item for item in inventory["actions"]} + nodes = [] + for original in view["nodes"]: + node = deepcopy(original) + # Native name is display text; never humanize or infer a verb/object label. + node["text"] = actions[node["element"]].get("name") or node["element"] + if not isinstance(node["text"], str) or len(node["text"]) > 2000: + raise ValueError("Presentation supports native names up to 2000 characters") + nodes.append(node) + if len(nodes) > 128 or max(node["level"] for node in nodes) > 16: + raise ValueError("Presentation supports at most 128 nodes and 16 decomposition levels") + return {"version": VERSION, "id": view_id, "kind": "function_tree", "claim_scope": CLAIM, + "nodes": nodes, "view_checks": checks} + + +def render_function_tree(projection, *, profile=None, body="paper", target_width=1200, show_ids=False, labels=None): + """Content-fit documentary tree; explicit display wording never changes native facts.""" + if type(show_ids) is not bool: + raise ValueError("show_ids must be a boolean") + if type(target_width) not in (int, float) or not math.isfinite(target_width) or not 320 <= target_width <= 8192: + raise ValueError("Target width must be between 320 and 8192 pixels") + if projection.get("kind") != "function_tree" or projection.get("version") != VERSION: + raise ValueError("Expected bounded function-tree presentation version") + raw_nodes = [{key: n[key] for key in ("id", "element", "level", "parent")} for n in projection["nodes"]] + inventory = {"documents": [{"name": "cseViews", "owner_kind": "Package", "body": + "CSE-VIEWS/1.0\n" + json.dumps({"views": [{"id": projection["id"], "kind": "function_tree", "nodes": raw_nodes}]})}], + "parts": [], "actions": [{"qualified_name": n["element"], "name": n["text"]} for n in projection["nodes"]]} + nodes = project_function_tree(inventory, projection["id"])["nodes"] + wording = apply_labels(projection, labels) + style = presentation_style(profile, body=body) + palette = load_profile(profile)['palette'] + by_id = {n['id']: n for n in nodes} + children = {n['id']: [] for n in nodes} + for n in nodes: + if n['parent'] is not None: + children[n['parent']].append(n['id']) + root = next(n['id'] for n in nodes if n['parent'] is None) + role_profile = function_tree_roles(profile) + families, roles = role_profile['branch_pairs'], {root: role_profile['root_pair']} + def assign(key, family): + roles[key] = family + for child in children[key]: + assign(child, family) + for index, key in enumerate(children[root]): + assign(key, families[index % len(families)]) + boxes = {} + size, padding, key_width = style['body_size'], 20, 6 + line_height = math.ceil(size * 1.4) + for n in nodes: + key = n['id'] + weight = 700 if children[key] else 400 + lines = wrap_text(wording['labels'][key], 300, size, weight=weight) + footer = ([f"{len(children[key])} subfunction" + ('s' if len(children[key]) != 1 else '')] if children[key] else []) + if show_ids: + footer.extend(wrap_text(f"{key} · level {n['level']}", 300, 16)) + content_width = max([text_width(line, size, weight=weight) for line in lines] + + [text_width(line, 16 if ' · level ' in line or (show_ids and not children[key]) else 18) for line in footer] + [100]) + label_height = 2 * padding + len(lines) * line_height + footer_height = 20 + 25 * len(footer) if footer else 0 + boxes[key] = {'width': math.ceil(content_width + 2 * padding + key_width + 8), + 'height': label_height + footer_height, 'label_height': label_height, + 'lines': lines, 'footer': footer, 'weight': weight} + subtrees = {} + def measure(key): + box, kids = boxes[key], children[key] + mode = 'leaf' if not kids else ('stack' if all(not children[c] for c in kids) else 'branches') + for c in kids: + measure(c) + if mode == 'leaf': + width, height = box['width'], box['height'] + elif mode == 'stack': + width = max(box['width'], max(boxes[c]['width'] for c in kids) + 32) + height = box['height'] + 56 + sum(boxes[c]['height'] for c in kids) + 18 * (len(kids)-1) + else: + width = max(box['width'], sum(subtrees[c]['width'] for c in kids) + 48 * (len(kids)-1)) + height = box['height'] + 64 + max(subtrees[c]['height'] for c in kids) + subtrees[key] = {'width': width, 'height': height, 'mode': mode} + measure(root) + width = math.ceil(max(target_width, subtrees[root]['width'] + 96, + text_width('Branch colors identify documentary groups; lines show decomposition.', 18) + 96)) + style = presentation_style(profile, body=body, target_width=target_width, canvas_width=width) + smallest = 16 if show_ids else 18 + if smallest * target_width / width < 16: + raise ValueError('Target too small for 16px labels and counts; use native width or scrolling') + title_text = (projection['id'] + ' - ' if show_ids else '') + wording['title'] + title_lines = wrap_text(title_text, width - 96, style['title_size'], 'Inter', 700) + title_leading = math.ceil(style['title_size'] * 1.2) + graph_top = 48 + len(title_lines) * title_leading + 76 + height = math.ceil(graph_top + subtrees[root]['height'] + 96) + if width > 20000 or height > 20000: + raise ValueError('Presentation exceeds bounded 20000-unit canvas; select a smaller view') + positions, edge_points = {}, {} + def place(key, left, top): + b, tree, kids = boxes[key], subtrees[key], children[key] + x = left + (tree['width'] - b['width']) / 2 + positions[key] = (x, top) + if tree['mode'] == 'stack': + stack_width = max(boxes[c]['width'] for c in kids) + 32 + leaf_left = left + (tree['width'] - stack_width) / 2 + 32 + spine = leaf_left - 24 + y = top + b['height'] + 56 + for c in kids: + positions[c] = (leaf_left, y) + edge_points[c] = {'from_side': 'bottom', 'to_side': 'left', 'points': + [(x+b['width']/2, top+b['height']), (x+b['width']/2, top+b['height']+24), + (spine, top+b['height']+24), (spine, y+boxes[c]['height']/2), + (leaf_left, y+boxes[c]['height']/2)]} + y += boxes[c]['height'] + 18 + elif tree['mode'] == 'branches': + kids_width = sum(subtrees[c]['width'] for c in kids) + 48 * (len(kids)-1) + cursor = left + (tree['width'] - kids_width)/2 + for c in kids: + cy = top + b['height'] + 64 + place(c, cursor, cy) + cx, _ = positions[c] + edge_points[c] = {'from_side': 'bottom', 'to_side': 'top', 'points': + [(x+b['width']/2, top+b['height']), (x+b['width']/2, top+b['height']+32), + (cx+boxes[c]['width']/2, top+b['height']+32), (cx+boxes[c]['width']/2, cy)]} + cursor += subtrees[c]['width'] + 48 + place(root, (width-subtrees[root]['width'])/2, graph_top) + def text(lines, x, baseline, fontsize, color, *, family='Space Grotesk', weight=400, role='label', leading=None): + leading = leading or math.ceil(fontsize*1.4) + attrs = f'xml:space="preserve" data-role="{role}" x="{x:g}" y="{baseline:g}" fill="{color}" font-family="{family}" font-size="{fontsize}" font-weight="{weight}"' + if len(lines) == 1: + return f'{escape(lines[0])}' + return f'' + ''.join(f'{escape(line)}' for i,line in enumerate(lines)) + '' + svg = [f'', + f'{escape(title_text)}', f'{escape(CLAIM)}', + '', + f'', + text(title_lines,48,48,style['title_size'],style['ink'],family='Inter',weight=700,role='title',leading=title_leading), + text(['Documentary function hierarchy'],48,48+len(title_lines)*title_leading+6,18,style['ink'],role='subtitle')] + shared_routes = [] + for parent, kids in children.items(): + if not kids: + continue + if subtrees[parent]['mode'] == 'branches': + first = edge_points[kids[0]]['points'] + drops = [edge_points[c]['points'][2] for c in kids] + segments = [[first[0],first[1]],[(min(p[0] for p in drops),first[1][1]), + (max(p[0] for p in drops),first[1][1])]] + color = style['ink'] + else: + first,last = edge_points[kids[0]]['points'],edge_points[kids[-1]]['points'] + segments = [[first[0],first[1],first[2],last[-2]]] + color = style['ink'] if parent == root else role_profile['pairs'][roles[parent]]['connector_color'] + commands = ['M ' + ' L '.join(f'{x:g} {y:g}' for x,y in points) for points in segments] + svg.append(f'') + shared_routes.append({'parent':parent,'segments':segments,'color':color}) + for key, edge in edge_points.items(): + # Shared trunks are painted once; this path is the uniquely owned drop. + points = edge['points'][-2:] + path = 'M ' + ' L '.join(f'{x:g} {y:g}' for x,y in points) + svg.append(f'') + geometry = [] + for node in nodes: + key = node['id']; x,y = positions[key]; b = boxes[key] + pair = role_profile['pairs'][roles[key]] + fill = pair['fill_color'] if children[key] else style['fill'] + border = pair['frame_color'] + if contrast(style['ink'], fill) < 4.5 or min(contrast(border, fill), contrast(border, style['paper'])) < 3: + raise ValueError('Insufficient contrast for local tree presentation roles') + attrs = ' '.join(f'data-{k}="{escape(str(node[k]),quote=True)}"' for k in ('id','element','level','parent')) + f' data-native-name="{escape(node["text"],quote=True)}"' + svg.extend([f'', f'', + f'', + text(b['lines'], x+padding+key_width, y+padding+size, size, style['ink'], weight=b['weight'])]) + if b['footer']: + fy = y + b['label_height'] + svg.append(f'') + for index,line in enumerate(b['footer']): + is_count = bool(children[key]) and index == 0 + svg.append(text([line],x+padding+key_width,fy+25+index*25,18 if is_count else 16,style['ink'],role='count' if is_count else 'metadata')) + svg.append('') + geometry.append({'id':key,'x':x,'y':y,'width':b['width'],'height':b['height'], + 'label_lines':b['lines'],'label_font_weight':b['weight'],'label_baseline':y+padding+size, + 'label_region_height':b['label_height'],'footer_lines':b['footer']}) + svg.extend([text(['Branch colors identify documentary groups; lines show decomposition.'],48,height-32,18,style['ink'],role='legend'),'']) + ledger = {'version':VERSION,'body':body,'target_width':target_width,'canvas_width':width,'canvas_height':height, + 'claim_scope':CLAIM,'show_ids':show_ids,'display':wording,'geometry':geometry, + 'edges':[{'parent':by_id[k]['parent'],'child':k,**v} for k,v in edge_points.items()], + 'shared_routes':shared_routes, + 'layout':{'kind':'balanced-branches-with-stacked-leaves','padding':padding,'line_height':line_height, + 'group_gap':48,'leaf_gap':18,'max_label_width':300, + 'scope':'Measured advance-based wrapping; actual loaded glyph bounds require browser inspection.'}, + 'presentation_roles':{'root':'profile-selected contrasting pair','branches':'independent fill, frame/accent and connector references', + 'profile':role_profile,'node_pairings':roles, + 'meaning':'Local navigation only; no universal engineering or status semantics','body_status':'provisional'}, + 'font_metrics_sha256':_digest((ASSETS/'fonts/metrics.json').read_bytes()),'style':style} + return '\n'.join(svg)+'\n',ledger + + +def write_presentation(model_paths, output, *, view_id, profile=None, body="paper", target_width=1200, show_ids=False, labels=None): + """Acquire a native inventory and write a new, independently editable bundle.""" + output = Path(output) + if output.exists() or output.is_symlink(): + raise FileExistsError(f"Presentation output already exists: {output}") + paths = [Path(model_paths)] if isinstance(model_paths, (str, Path)) else [Path(p) for p in model_paths] + report = check_sysml(paths, syntax_only=True, view_profile={"version": 1, "views": [{"id": view_id, "kind": "function_tree"}]}) + if report["status"] != "pass": + return report + projection = project_function_tree(report["inventory"], view_id) + validated_profile = load_profile(profile) + validated_labels = load_labels(labels) + svg, presentation = render_function_tree(projection, profile=validated_profile, body=body, target_width=target_width, show_ids=show_ids, labels=validated_labels) + # Capture source bytes and verify parser pins before creating any output. + sources = {} + for path in paths: + raw = path.read_bytes() + resolved = path.resolve() + matched = next((entry for entry in report["model_files"] if entry["path"] == str(resolved) or + (Path(__file__).resolve().parents[1] / entry["path"]).resolve() == resolved), None) + if matched is None or matched["sha256"] != _digest(raw): + raise ValueError("Model source changed during presentation acquisition") + sources[resolved.as_posix()] = raw + files = {"view.svg": svg.encode(), "semantic-ledger.json": _json(projection).encode(), + "presentation.json": _json(presentation).encode()} + if validated_labels is not None: + files["display-labels.json"] = _json(validated_labels).encode() + # Plain offline HTML: exact SVG, embedded fonts, scroll and native browser zoom. + html = '\nFunction decomposition

' + escape(CLAIM) + '

Scroll to inspect the diagram; use browser zoom to enlarge.

' + svg + '
\n' + files["index.html"] = html.encode() + font_manifest_raw = (ASSETS / 'fonts/manifest.json').read_bytes() + font_manifest = json.loads(font_manifest_raw) + files['font-notices/manifest.json'] = font_manifest_raw + for face in font_manifest['faces']: + files['font-notices/' + face['notice']] = (ASSETS / 'fonts' / face['notice']).read_bytes() + source_manifest = [] + for i, (path, raw) in enumerate(sorted(sources.items())): + companion = f"sources/model-{i+1}.sysml" + files[companion] = raw + source_manifest.append({"file": companion, "name": Path(path).name, "sha256": _digest(raw)}) + manifest = {"version": VERSION, "view_id": view_id, "claim_scope": CLAIM, "show_ids": show_ids, + "model_sha256": report["model_sha256"], "parser": report["parser"], + "checker_version": report["checker_version"], "sources": source_manifest, + "style_sha256": _digest(_json(validated_profile).encode()), + "font_manifest_sha256": _digest(font_manifest_raw), + "font_metrics_sha256": _digest((ASSETS / "fonts/metrics.json").read_bytes()), + "implementation_sha256": {str(Path('verifier') / name): _digest((Path(__file__).parent / name).read_bytes()) + for name in ('visual_projection.py', 'visual_style.py', 'visual_labels.py', 'engineering_views.py', 'sysml.py')}, + "files": {name: _digest(raw) for name, raw in sorted(files.items())}, + "editable_source": "sources/*.sysml are exact source companions; edit and regenerate with sysml_present.py. No editor roundtrip claim."} + files['manifest.json'] = _json(manifest).encode() + output.mkdir() # exclusive: never overwrite an existing presentation + try: + (output / 'sources').mkdir() + (output / 'font-notices').mkdir() + for name, raw in sorted(files.items()): + (output / name).write_bytes(raw) + except OSError: + shutil.rmtree(output) + raise + return {"status": "pass", "claim_scope": CLAIM, "manifest": manifest} diff --git a/verifier/visual_style.py b/verifier/visual_style.py new file mode 100644 index 0000000..4a2e28e --- /dev/null +++ b/verifier/visual_style.py @@ -0,0 +1,231 @@ +"""Opt-in presentation values; never model meaning or engineering acceptance.""" +import base64 +from copy import deepcopy +from functools import lru_cache +from hashlib import sha256 +import json +import math +from pathlib import Path +import re + +ASSETS = Path(__file__).resolve().parents[1] / "assets/diagram-style" +HEX = re.compile(r"#[0-9a-fA-F]{6}\Z") + + +def _unique(pairs): + out = {} + for key, value in pairs: + if key in out: + raise ValueError(f"Duplicate profile key: {key}") + out[key] = value + return out + + +def _color(value): + if not isinstance(value, str) or not HEX.fullmatch(value): + raise ValueError("Colors must be opaque #RRGGBB values") + return value + + +def contrast(foreground, background): + """sRGB relative luminance ratio for opaque fields; not a readability verdict.""" + def luminance(color): + channels = [int(_color(color)[i:i+2], 16) / 255 for i in (1, 3, 5)] + channels = [v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in channels] + return sum(v * weight for v, weight in zip(channels, (0.2126, 0.7152, 0.0722))) + a, b = sorted((luminance(foreground), luminance(background))) + return (b + 0.05) / (a + 0.05) + + +def load_profile(path=None): + data = deepcopy(path) if isinstance(path, dict) else json.loads( + Path(path or ASSETS / "profile.json").read_text(), object_pairs_hook=_unique, + parse_constant=lambda value: (_ for _ in ()).throw(ValueError(f"Nonfinite {value}"))) + expected = {"version", "name", "direction_status", "palette", "fonts", "body_variants", "numeric_proposal", "limits"} + if not isinstance(data, dict) or not expected <= set(data) or set(data) - expected - {"function_tree_roles"} or type(data["version"]) is not int or data["version"] != 1: + raise ValueError("Expected visual profile version 1 and exact documented fields") + palette = data["palette"] + if not isinstance(palette, dict) or set(palette) != {"paper", "ink", "light", "middle", "families"}: + raise ValueError("Malformed palette") + for key in ("paper", "ink", "light", "middle"): + _color(palette[key]) + families = palette["families"] + if not isinstance(families, dict) or set(families) != {"blue", "red", "amber", "green", "cyan", "violet", "tangerine", "berry"}: + raise ValueError("Expected eight named palette families") + for colors in families.values(): + if not isinstance(colors, dict) or set(colors) != {"shade", "base", "tint"}: + raise ValueError("Family needs shade, base and tint") + for value in colors.values(): + _color(value) + if data["fonts"] != {"title": "Inter", "body": "Space Grotesk"}: + raise ValueError("This slice qualifies only the bundled Inter / Space Grotesk faces") + if data["direction_status"] != "selected_direction" or data["body_variants"] != {"status": "provisional", "values": ["paper", "white", "tint"]}: + raise ValueError("Preserve selected direction and provisional body-variant distinction") + numeric = data["numeric_proposal"] + if not isinstance(numeric, dict) or set(numeric) != {"status", "title_size", "body_size", "stroke_display_px"} or numeric["status"] != "slice_specific_proposal": + raise ValueError("Numeric defaults must remain a slice-specific proposal") + for key, low, high in (("title_size", 32, 48), ("body_size", 20, 24), ("stroke_display_px", 1.5, 3)): + v = numeric[key] + if type(v) not in (int, float) or not math.isfinite(v) or not low <= v <= high: + raise ValueError(f"Invalid proposed {key}") + if data["limits"] != {"glyphs": "printable_ascii_plus_middle_dot", "color_semantics": "none"}: + raise ValueError("Unsupported glyph or semantic color scope") + for fill in (palette["paper"], "#ffffff", _tint(palette["families"]["violet"]["base"])): + if contrast(palette["ink"], fill) < 4.5: + raise ValueError("Insufficient text contrast for a body variant") + if contrast(palette["families"]["tangerine"]["shade"], palette["families"]["violet"]["tint"]) < 4.5: + raise ValueError("Insufficient orange-on-violet text contrast") + if "function_tree_roles" not in data: + # Version-1 custom profiles inherit the explicit bundled local defaults. + bundled = json.loads((ASSETS / "profile.json").read_text(), object_pairs_hook=_unique) + data["function_tree_roles"] = deepcopy(bundled["function_tree_roles"]) + _tree_roles(data) + return deepcopy(data) + + +def _palette_reference(palette, reference, tone): + if not isinstance(reference, str) or reference.count('.') != 1: + raise ValueError('Expected a named palette family/tone reference') + family, variant = reference.split('.') + if family not in palette['families'] or variant != tone: + raise ValueError('Unsupported palette family or role tone') + return palette['families'][family][variant] + + +def _tree_roles(data): + roles = data['function_tree_roles'] + if not isinstance(roles, dict) or set(roles) != {'status','root_pair','branch_pairs','shared_fanout','pairs'}: + raise ValueError('Expected explicit function-tree role fields') + if roles['status'] != 'slice_specific_proposal' or roles['shared_fanout'] != 'ink': + raise ValueError('Preserve scoped proposal status and neutral shared fanout') + pairs = roles['pairs'] + if not isinstance(pairs, dict) or not 1 <= len(pairs) <= 8 or any(not isinstance(key,str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]{0,63}',key) for key in pairs): + raise ValueError('Expected bounded named presentation pairs') + branches = roles['branch_pairs'] + if not isinstance(branches,list) or not 1 <= len(branches) <= 8 or any(not isinstance(key,str) or key not in pairs for key in branches) or len(branches) != len(set(branches)): + raise ValueError('Unknown, repeated or invalid branch presentation pair') + if not isinstance(roles['root_pair'],str) or roles['root_pair'] not in pairs: + raise ValueError('Unknown root presentation pair') + palette = data['palette'] + resolved = {} + for name, pair in pairs.items(): + if not isinstance(pair,dict) or set(pair) != {'fill','frame','connector'}: + raise ValueError('Pair needs independent fill, frame and connector roles') + fill = _palette_reference(palette,pair['fill'],'tint') + frame = _palette_reference(palette,pair['frame'],'shade') + connector = _palette_reference(palette,pair['connector'],'shade') + ratios = {'text':contrast(palette['ink'],fill),'frame_on_header':contrast(frame,fill), + 'frame_on_paper':contrast(frame,palette['paper']),'connector_on_paper':contrast(connector,palette['paper'])} + if ratios['text'] < 4.5 or min(ratios[k] for k in ratios if k != 'text') < 3: + raise ValueError('Insufficient text or nontext contrast for presentation pair') + # Light leaf bodies remain independent of the colored header fill. + for leaf in (palette['paper'],'#ffffff',_tint(palette['families']['violet']['base'])): + if contrast(frame,leaf) < 3: + raise ValueError('Insufficient frame contrast on a provisional leaf body') + resolved[name] = {**pair,'fill_color':fill,'frame_color':frame,'connector_color':connector,'contrast':ratios} + return {'status':roles['status'],'root_pair':roles['root_pair'],'branch_pairs':deepcopy(branches), + 'shared_fanout_color':palette['ink'],'pairs':resolved} + + +def function_tree_roles(profile=None): + """Resolve explicit local role references; never derive engineering categories.""" + return _tree_roles(load_profile(profile)) + + +def _tint(color): + return "#" + "".join(f"{math.floor(0.94 * 255 + 0.06 * int(color[i:i+2], 16) + 0.5):02x}" for i in (1, 3, 5)) + + +def require_supported_text(text): + if not isinstance(text, str) or not text or any(not (32 <= ord(c) <= 126 or c == "\u00b7") for c in text): + raise ValueError("This slice qualifies printable ASCII plus middle dot only; other glyphs need qualification") + + +def font_css(): + manifest = json.loads((ASSETS / "fonts/manifest.json").read_text()) + css = [] + included_notices = set() + for item in manifest["faces"]: + raw = (ASSETS / "fonts" / item["file"]).read_bytes() + if sha256(raw).hexdigest() != item["sha256"]: + raise ValueError("Bundled font integrity mismatch: " + item["file"]) + notice = (ASSETS / "fonts" / item["notice"]).read_bytes() + if sha256(notice).hexdigest() != item["notice_sha256"]: + raise ValueError("Bundled font notice integrity mismatch") + if item["notice"] not in included_notices: + notice_text = notice.decode("utf-8") + if "*/" in notice_text or " max_width and end > start: + break + if advance > max_width: + raise ValueError('Text region cannot contain one qualified glyph') + width += advance + end += 1 + if text[end-1] == ' ': + last_space = end + if end < len(text) and last_space is not None: + end = last_space + lines.append(text[start:end]) + start = end + return lines + + +@lru_cache(maxsize=1) +def _font_metrics(): + metrics = json.loads((ASSETS / 'fonts/metrics.json').read_text()) + manifest = json.loads((ASSETS / 'fonts/manifest.json').read_text()) + expected = {item['family'] + ':' + str(item['weight']): item['sha256'] for item in manifest['faces']} + chars = set(chr(i) for i in range(32, 127)) | {'\u00b7'} + if metrics.get('version') != 1 or set(metrics.get('faces', {})) != set(expected): + raise ValueError('Unsupported qualified font metrics') + for key, face in metrics['faces'].items(): + if face.get('font_sha256') != expected[key] or face.get('units') != 1000 or set(face.get('advances', {})) != chars: + raise ValueError('Font metrics do not match bundled face or qualified coverage') + if any(type(v) not in (int, float) or not math.isfinite(v) or v <= 0 for v in face['advances'].values()): + raise ValueError('Invalid measured glyph advance') + return metrics