This document collects the coding guidelines, design conventions, and engineering lessons that apply to work in this repository. AGENTS.md covers how to operate (workflow, docs upkeep, commit habits); this document covers how to write and design code here.
Prefer:
- Composition over inheritance
- Dependency Injection
- SOLID principles
- Clean Architecture
- Hexagonal Architecture where appropriate
- Explicit interfaces
- Small cohesive components
- Immutable models where practical
- Testability
- Separation of concerns
Avoid:
- Static platform checks throughout the codebase
- Platform-specific logic leaking into business logic
- Tight coupling
- Hidden dependencies
- Global state
Platform-specific behavior should exist only behind interfaces.
Examples include:
- Text measurement
- Font resolution
- Graphics context
- Image loading
- File system interactions
- Printing
- Native drawing APIs
Never allow application logic to directly depend on System.Drawing or Windows APIs.
Prefer adapters for platform implementations.
Example:
IGraphicsContext
↓
WindowsGraphicsContext
LinuxGraphicsContext
MacGraphicsContext
Similarly:
ITextMeasurer
↓
WindowsTextMeasurer
LinuxTextMeasurer
TestTextMeasurer
Design rendering as a plugin architecture.
Preferred concepts include:
IRendererIRenderingExtensionIOutputWriterIGraphicsContextIFontResolver
Future third-party renderers should be loadable without modifying the core engine.
Potential implementations include:
- QuestPDF
- SkiaSharp
- PDFSharp
- ImageSharp
- OpenXML
- Custom renderers
Favor a discoverable plugin model.
Possible mechanisms include:
- Reflection
- Assembly scanning
- Dependency Injection
- MEF-style discovery
- Explicit registration
Do not tightly couple renderer implementations to the core library.
- Avoid service location.
- Prefer constructor injection.
- Dependencies should be interfaces whenever practical.
Every abstraction should have at least three implementations:
- Production implementation
- Platform implementation
- Test implementation
Example:
ITextMeasurer
↓
WindowsTextMeasurer
LinuxTextMeasurer
RecordingTextMeasurer
Test implementations should verify behavior rather than visual output.
Prefer:
- Behavioral testing
- Golden master tests
- Snapshot tests
- Recording adapters
- Deterministic rendering
- Avoid pixel-perfect comparisons whenever possible.
This project is, in large part, an architectural investigation (porting a Windows-only rendering engine to be cross-platform). When analyzing code:
- Read before modifying.
- Document before refactoring.
- Trace call graphs.
- Identify responsibilities.
- Record evidence.
- Never speculate.
Every architectural conclusion should reference source files (and ideally line numbers).
Write code that is:
- Small
- Readable
- Well documented
- Incrementally refactorable
- Easy to test
- Easy to extend
- Favor explicitness over cleverness.
Favor:
- Adapter
- Strategy
- Facade
- Factory
- Composition
- Decorator
- Dependency Injection
- Ports and Adapters
- Repository (where appropriate)
Avoid unnecessary abstraction. Do not introduce design patterns unless they clearly simplify the architecture.
Evaluate all proposals against:
- Maintainability
- Extensibility
- Portability
- Testability
- Performance
- Reliability
- Developer Experience
- Backwards Compatibility
- Security
- Complexity
Every non-trivial proposal should consider:
- Technical risks
- Migration risks
- Performance risks
- Compatibility risks
- Testing impact
- Suggested mitigations
When generating documentation, explain:
- Purpose
- Responsibilities
- Dependencies
- Extension points
- Risks
- Technical debt
- Unknowns
Prefer diagrams and call graphs where appropriate. (Where these belong day-to-day — docs/ vs. tasks/ vs. TODO.md — is covered in AGENTS.md's "Internal Documentation" section.)
For significant decisions, generate ADRs including:
- Context
- Problem
- Options
- Decision
- Consequences
- Future considerations
When generating technical specifications, include:
- Overview
- Goals
- Non-goals
- Requirements
- Constraints
- Architecture
- Interfaces
- Risks
- Testing strategy
- Migration strategy
- Open questions
When generating PRDs, include:
- Problem Statement
- Goals
- Success Metrics
- User Stories
- Acceptance Criteria
- Non-functional Requirements
- Performance
- Security
- Accessibility
- Maintainability
- Deployment considerations
- Future roadmap
These apply to any similar incremental type-abstraction or interface-introduction effort:
- Dual-overload strategy: don't retype an existing method/field in place if it still has real concrete callers. Add a new, separately-named interface-typed sibling instead (e.g.
GetHatchBrushResourcenext toGetHatchBrush), and migrate real callers to it one at a time. This is what keeps a large migration incremental and revert-safe. - Bridge-at-the-sink: when a concrete resource can't reasonably be retyped at its source (a public model property, a self-contained legacy geometry helper), wrap/reconstruct it into the interface type only at the point it's consumed, rather than forcing the source to change.
- The "large atomic pass" trap: shared concrete fields/arrays on a helper class often look individually convertible per-getter, but are all consumed together by one call downstream — converting one getter without the whole class, its producers, and its consumer in one pass just adds unreachable dead code. Identify these and do them as one deliberate pass, not sliced.
- Verification gate: every increment must have
dotnet build(0 errors) + full test suite passing + zero baseline diffs before being considered done. For previously-uncovered render paths, generate a "before" baseline viagit stash push --keep-indexon just the engine files being converted, render through the pre-conversion code, pop the stash, and confirm byte-for-byte match. - Don't force an abstraction whose semantics can't be verified end-to-end — document a genuine gap honestly rather than risk a subtly wrong port.
- A scene rendering without throwing does not mean it rendered correctly. The most serious bug found in this migration (
SkiaGraphicsPath.AddLinesilently fragmenting a multi-segment polyline into disconnected zero-area segments) threw no exception anywhere — it was only caught by eyeballing a rendered PNG against its GDI+ counterpart. Treat "compiles and runs" and "renders the right pixels" as two separate claims; verify both before promoting a baseline, especially for a newly-reached code path. - A "blocked"/"architecturally blocked" label can outlive the reasoning that produced it. Twice in this migration (Gauge's
XamlRenderer.cs, and Chart's D3-vs-"3D rendering is impossible" conflation), a scoping conclusion from an earlier pass turned out not to hold once re-derived from the current code. Before accepting or extending a "blocked" note in a task doc, re-trace the actual blocker yourself rather than trusting the label — it costs one grep pass and has twice turned "not started" into "actually already easy." - A virtual method's overrides all share one signature — trace the whole override family before concluding a return-type change is blocked. When one override in the family has a genuine concrete-type entanglement (e.g. Chart's
Draw3DSurface/Draw3DPolygonfamily, entangled viaClipTopPoints/ClipBottomPoints) and the others don't, the fix is to convert the entangled dependency first, not to declare the whole family permanently blocked — the entanglement is usually narrower than "the whole virtual slot." Converting it later (Milestone D3-real) confirmed it: a re-grep foundClipTopPoints/ClipBottomPointsactually declared onLineChart.cs, notAreaChart.csas the original scoping note said — even a correct top-line conclusion ("this is where the entanglement is") can carry a wrong file/line citation forward; re-verify the citation, not just the claim, before resuming from an old note. - When prototyping against a native-interop library, prefer an already-proven high-level wrapper over hand-rolling the lower-level API, even for a throwaway prototype. PDF's HarfBuzzSharp shaping prototype hand-built
HarfBuzzSharp.Face/Fontfrom a raw font blob and hit an intermittent native access violation that only appeared after many cumulative shape calls across a test run — never within one isolated test. Switching toSkiaSharp.HarfBuzz.SKShaper(already proven stable elsewhere in this repo) fixed it. The lesson generalizes beyond this one bug: composing several independently-tested prototypes together (not just unit-testing each in isolation) is often what surfaces this class of defect — budget a composition/integration step before trusting any prototype, especially one touching native memory. Seedocs/decisions.md'sSKShaperentry. - A public API's declared return type can be a harder wall than any internal abstraction gap.
ChartImage.GetImage(float):Bitmapcannot be fixed by any amount of interface-typing internally — it's an external contract requiring a concreteSystem.Drawing.Bitmap, which can't even be constructed on Linux. Before scoping "make X cross-platform," check whether X's real production caller actually goes through the method with the hard external contract, or through a sibling method (here,Chart.Save(Stream,...)/ChartImage.SaveImage) that was already abstracted — conflating the two wastes effort chasing a permanently unfixable entry point instead of the reachable one. - A planned increment's mechanism working in isolation doesn't mean its precondition holds. PDF's base-14 text path was going to be extended with bidi/RTL reordering (correct itemization → correct visual order for Hebrew/Arabic runs). The reordering logic worked, but was dead code: base-14 fonts only support
/Encoding /WinAnsiEncoding, which has zero Hebrew/Arabic/Cyrillic/Greek/CJK code points, so there were no real glyphs to reorder. A passing test suite would not have caught this — it took reasoning about what the drawn output would actually contain, not just what the code would do, to notice the precondition was false. Surface this kind of finding as a scope question rather than shipping the mechanism as if it were real progress. Seedocs/decisions.md's "reverse the font-embedding deferral" entry (2026-07-26). - Before adding a new third-party package, check whether an already-mandatory dependency covers the same job, and check what the new package pulls in transitively.
SixLabors.ImageSharpwas added standalone for Excel embedded-image decode/format-sniffing/PNG-encode — a jobSkiaSharp(already mandatory for Chart/Gauge/PDF text) already covered, so it was pure redundant surface area. Removing it also surfaced thatPdfSharpCoreitself transitively depends on an old, CVE-flaggedSixLabors.ImageSharpbuild — dropping the direct reference would have silently downgraded the resolved version instead of eliminating the package, since NuGet still needs some version to satisfyPdfSharpCore. Rundotnet list package --include-transitivebefore adding a package, not just before removing one. Seedocs/decisions.md's "replace SixLabors.ImageSharp with SkiaSharp" entry (2026-07-28).