feat(dart): add the Dart core port (dicebear_core)
Adds src/dart/core, a pub.dev package that renders byte-identical SVG to the JavaScript reference and the PHP/Python/Rust/Go ports: Style/Style.parse, Avatar, OptionsDescriptor, Color, a keyed FNV-1a + Mulberry32 PRNG with web-safe 32-bit masking, JS-compatible number formatting and full-Unicode uppercasing, and runtime schema validation via dicebear_schema + json_schema. Tests run the shared parity fixtures on the VM plus an embedded web-parity suite under dart2js/Chrome. CI gains test-dart (VM + Chrome) and publish-pub jobs, with a pubspec bump in scripts/version.mjs. Docs add a how-to-use/dart-library page and Dart snippets/tabs across the playground, style pages, integration sections and guides. Dart is added to the cross-port doc comments, and the initials parity fixtures gain full-casing cases (ss, fi-ligature, Greek iota-subscript) now matched by every port.
This commit is contained in:
@@ -99,3 +99,43 @@ jobs:
|
||||
run: cargo publish
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
|
||||
|
||||
publish-pub:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: dart-lang/setup-dart@v1
|
||||
|
||||
# Automated publishing (OIDC via setup-dart, no stored token). Configure
|
||||
# on pub.dev: package dicebear_core, repository dicebear/dicebear, tag
|
||||
# pattern v{{version}}. Unlike the Go and PHP ports, pub.dev publishes
|
||||
# straight from the monorepo subdirectory — no split repository.
|
||||
- name: Copy the project changelog into the package
|
||||
# CHANGELOG.md inside the package is a git-ignored build artifact:
|
||||
# pub.dev expects it in the package directory, while the repository
|
||||
# maintains a single changelog at the root.
|
||||
run: cp CHANGELOG.md src/dart/core/CHANGELOG.md
|
||||
# The first release is published manually (pub.dev's automated-publishing
|
||||
# settings only exist once the package does), so skip cleanly when the
|
||||
# pubspec version is already live. This also makes re-runs of the
|
||||
# workflow safe.
|
||||
- name: Check whether this version is already on pub.dev
|
||||
id: pub-version
|
||||
working-directory: src/dart/core
|
||||
run: |
|
||||
VERSION=$(sed -n 's/^version: //p' pubspec.yaml)
|
||||
if curl -fsSL https://pub.dev/api/packages/dicebear_core \
|
||||
| jq -e --arg v "$VERSION" '.versions[] | select(.version == $v)' \
|
||||
> /dev/null; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish to pub.dev
|
||||
if: steps.pub-version.outputs.exists == 'false'
|
||||
working-directory: src/dart/core
|
||||
run: dart pub publish --force
|
||||
|
||||
@@ -121,3 +121,54 @@ jobs:
|
||||
- name: Run tests
|
||||
working-directory: src/go/core
|
||||
run: go test ./...
|
||||
|
||||
test-dart:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
# Lower bound = the pubspec `environment.sdk` floor; keep in sync.
|
||||
sdk: ['3.4', 'stable']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Use Dart ${{ matrix.sdk }}
|
||||
uses: dart-lang/setup-dart@v1
|
||||
with:
|
||||
sdk: ${{ matrix.sdk }}
|
||||
- name: Install dependencies
|
||||
working-directory: src/dart/core
|
||||
run: dart pub get
|
||||
- name: Check the embedded web fixtures are up to date
|
||||
# The web parity suite embeds a copy of the fixtures (dart2js cannot
|
||||
# read files); regenerate it and fail if it drifted from the canonical
|
||||
# tests/fixtures/parity.
|
||||
working-directory: src/dart/core
|
||||
run: |
|
||||
dart run tool/generate_web_fixtures.dart
|
||||
dart format test/parity/embedded_fixtures.dart
|
||||
git diff --exit-code test/parity/embedded_fixtures.dart
|
||||
- name: Check formatting
|
||||
working-directory: src/dart/core
|
||||
run: dart format --output=none --set-exit-if-changed .
|
||||
- name: Analyze
|
||||
working-directory: src/dart/core
|
||||
run: dart analyze --fatal-infos
|
||||
- name: Run tests
|
||||
working-directory: src/dart/core
|
||||
run: dart test
|
||||
- name: Run web-eligible tests on dart2js
|
||||
# The fixture suites are VM-only (they read disk via dart:io), so the
|
||||
# dart2js build's number formatting and 32-bit PRNG arithmetic is
|
||||
# asserted by the embedded web_parity_test under Chrome.
|
||||
# ubuntu-latest ships google-chrome-stable.
|
||||
working-directory: src/dart/core
|
||||
run: dart test -p chrome
|
||||
- name: Copy the project changelog into the package
|
||||
# CHANGELOG.md inside the package is a git-ignored build artifact:
|
||||
# pub.dev expects it in the package directory, while the repository
|
||||
# maintains a single changelog at the root.
|
||||
run: cp CHANGELOG.md src/dart/core/CHANGELOG.md
|
||||
- name: Validate the publishable archive
|
||||
working-directory: src/dart/core
|
||||
run: dart pub publish --dry-run
|
||||
|
||||
+11
-6
@@ -12,12 +12,17 @@ and this project adheres to
|
||||
|
||||
- **Core:** Every rendered SVG now starts with the generator comment
|
||||
`<!-- Generated by DiceBear (https://dicebear.com) -->` as the first child of
|
||||
the root `<svg>` element. The comment is byte-identical across the
|
||||
JavaScript, PHP, Python, Rust, and Go libraries. The byte output of every
|
||||
avatar changes as a result, including data URIs and content hashes, so
|
||||
consumers that compare rendered SVGs against stored snapshots need to update
|
||||
them. SVG optimizers that strip comments (e.g. SVGO with default settings)
|
||||
remove it again.
|
||||
the root `<svg>` element. The comment is byte-identical across the JavaScript,
|
||||
PHP, Python, Rust, Go, and Dart libraries. The byte output of every avatar
|
||||
changes as a result, including data URIs and content hashes, so consumers that
|
||||
compare rendered SVGs against stored snapshots need to update them. SVG
|
||||
optimizers that strip comments (e.g. SVGO with default settings) remove it
|
||||
again.
|
||||
- **Dart library:** A new Dart implementation (the
|
||||
[`dicebear_core`](https://pub.dev/packages/dicebear_core) package) that
|
||||
produces identical output to the JavaScript library when given the same styles
|
||||
and options. It validates style definitions and options against the shared
|
||||
schemas (via `dicebear_schema`) and pairs with the `dicebear_styles` package.
|
||||
|
||||
## [10.2.0] - 2026-06-10
|
||||
|
||||
|
||||
+43
-9
@@ -2,7 +2,7 @@
|
||||
|
||||
Thanks for your interest in contributing to DiceBear.
|
||||
|
||||
This is the main monorepo: the JavaScript, PHP, Python, Rust, and Go core
|
||||
This is the main monorepo: the JavaScript, PHP, Python, Rust, Go, and Dart core
|
||||
libraries, the CLI, the docs site, and the editor all live here. Repositories
|
||||
covering the JSON Schema, the avatar style definitions, the HTTP API, and the
|
||||
Figma exporter are separate and each have their own `CONTRIBUTING.md`:
|
||||
@@ -51,6 +51,9 @@ instructions below only cover this monorepo.
|
||||
`src/rust/core/`
|
||||
- For Go work: Go 1.23+ (CI runs on 1.23 to 1.25); test with `go test ./...` and
|
||||
check with `gofmt -l .` and `go vet ./...` inside `src/go/core/`
|
||||
- For Dart work: Dart SDK 3.4+ (CI runs on 3.4 and stable); test with
|
||||
`dart test` and check with `dart format --output=none --set-exit-if-changed .`
|
||||
and `dart analyze --fatal-infos` inside `src/dart/core/`
|
||||
|
||||
## Local setup
|
||||
|
||||
@@ -95,7 +98,8 @@ src/
|
||||
├── php/ # PHP port (Composer package `dicebear/core`)
|
||||
├── python/ # Python port (PyPI package `dicebear-core`)
|
||||
├── rust/ # Rust port (crates.io crate `dicebear-core`)
|
||||
└── go/ # Go port (module `github.com/dicebear/dicebear-go/v10`)
|
||||
├── go/ # Go port (module `github.com/dicebear/dicebear-go/v10`)
|
||||
└── dart/ # Dart port (pub.dev package `dicebear_core`)
|
||||
apps/
|
||||
├── docs/ # VitePress documentation site (dicebear.com), including the Playground
|
||||
└── editor/ # The in-browser editor (editor.dicebear.com)
|
||||
@@ -174,6 +178,26 @@ dependency. Nothing is vendored. Style definitions come from
|
||||
(`/v10`), so a major bump changes the path by hand; `scripts/version.mjs` only
|
||||
creates the Git tag the module proxy reads.
|
||||
|
||||
### Dart core (`src/dart/core/`)
|
||||
|
||||
```sh
|
||||
cd src/dart/core
|
||||
dart pub get
|
||||
dart test
|
||||
dart analyze --fatal-infos
|
||||
dart format --output=none --set-exit-if-changed .
|
||||
```
|
||||
|
||||
The Dart core reads the two draft-07 schemas from the `dicebear_schema` package
|
||||
(the Dart counterpart of `@dicebear/schema` / `dicebear/schema`) as a runtime
|
||||
dependency, validated with `package:json_schema`. Nothing is vendored.
|
||||
|
||||
`src/dart/core/CHANGELOG.md` is a git-ignored build artifact: pub.dev expects a
|
||||
changelog inside the package directory, so the CI workflows copy the repository
|
||||
root `CHANGELOG.md` there before `dart pub publish [--dry-run]`. This follows
|
||||
the same pattern as the generated `LICENSE` copies in the styles and schema
|
||||
repositories. Maintain the root changelog only.
|
||||
|
||||
### Cross-language parity
|
||||
|
||||
Every port must produce output **byte-identical** to the reference JavaScript
|
||||
@@ -190,6 +214,8 @@ each side consumes it:
|
||||
in `src/rust/core/`.
|
||||
- Go side: the in-package tests (`parity_test.go`, `avatars_test.go`), run via
|
||||
`go test ./...` in `src/go/core/`.
|
||||
- Dart side: the tests under `test/parity/`, run via `dart test` in
|
||||
`src/dart/core/`.
|
||||
|
||||
The fixtures cover `Fnv1a` (hash + hex), `Mulberry32` (chained sequences), every
|
||||
`Prng` method, number-to-string formatting (`numbers.json`, the `formatNumber`
|
||||
@@ -217,9 +243,9 @@ fixtures from the JS reference and commit the diff:
|
||||
npm run fixtures:parity
|
||||
```
|
||||
|
||||
The PHP, Python, Rust, and Go suites will then fail loudly until those sides are
|
||||
brought back in sync. That is the intended signal. If you only intend to touch
|
||||
one language, expect to update both before your PR can be merged.
|
||||
The PHP, Python, Rust, Go, and Dart suites will then fail loudly until those
|
||||
sides are brought back in sync. That is the intended signal. If you only intend
|
||||
to touch one language, expect to update both before your PR can be merged.
|
||||
|
||||
When porting DiceBear to another language, run these fixtures against your
|
||||
implementation to prove it conforms. See
|
||||
@@ -264,6 +290,9 @@ npm run build --workspace @dicebear/editor
|
||||
before you open a PR.
|
||||
- Go code is formatted with `gofmt` and vetted with `go vet`; run `gofmt -w .`
|
||||
and `go vet ./...` in `src/go/core/` before you open a PR.
|
||||
- Dart code is formatted with `dart format` and analyzed with
|
||||
`dart analyze --fatal-infos` (lints from `analysis_options.yaml`); run both in
|
||||
`src/dart/core/` before you open a PR.
|
||||
|
||||
## Releasing (maintainers only)
|
||||
|
||||
@@ -282,9 +311,10 @@ or `10.2.0-alpha.1`). The script will:
|
||||
|
||||
1. Update `version` in every `package.json` across the workspace
|
||||
2. Update internal workspace dependency references
|
||||
3. Update `version` in `src/python/core/pyproject.toml` and
|
||||
`src/rust/core/Cargo.toml` (the Python and Rust cores are not npm workspaces,
|
||||
so they are bumped explicitly to stay in lockstep)
|
||||
3. Update `version` in `src/python/core/pyproject.toml`,
|
||||
`src/rust/core/Cargo.toml`, and `src/dart/core/pubspec.yaml` (the Python,
|
||||
Rust, and Dart cores are not npm workspaces, so they are bumped explicitly to
|
||||
stay in lockstep)
|
||||
4. Sync `package-lock.json`
|
||||
5. Create a Git commit and tag (e.g. `v10.1.0`)
|
||||
|
||||
@@ -313,12 +343,16 @@ workflow, which:
|
||||
6. Publishes the Rust core `dicebear-core` to crates.io via trusted publishing
|
||||
(the `publish-rust` job): likewise no token; `cargo publish` builds and
|
||||
uploads `src/rust/core` in one step
|
||||
7. Publishes the Dart core `dicebear_core` to pub.dev via automated publishing
|
||||
(the `publish-pub` job): likewise no token; pub.dev verifies the `v<version>`
|
||||
tag against the pubspec version and publishes `src/dart/core` straight from
|
||||
the monorepo
|
||||
|
||||
The PHP port is the exception: Composer/Packagist consumes one Git repository
|
||||
per package rather than a monorepo subdirectory, so `split-php-core.yml` mirrors
|
||||
`src/php/core` (tags included) to the standalone
|
||||
[`dicebear/dicebear-php`](https://github.com/dicebear/dicebear-php) repository,
|
||||
and Packagist publishes `dicebear/core` from that mirror. All four ports ride
|
||||
and Packagist publishes `dicebear/core` from that mirror. All five ports ride
|
||||
the same version the monorepo tagged.
|
||||
|
||||
## Licensing
|
||||
|
||||
@@ -13,12 +13,12 @@ individual features like hair or glasses.
|
||||
[Documentation](https://www.dicebear.com/introduction) |
|
||||
[Editor](https://editor.dicebear.com)
|
||||
|
||||
## One library, five languages
|
||||
## One library, six languages
|
||||
|
||||
DiceBear 10 ships as native libraries for JavaScript, PHP, Python, Rust, and Go.
|
||||
Every port passes a shared test suite that requires byte-identical SVG output to
|
||||
the JavaScript reference. Generate an avatar in the browser, regenerate it later
|
||||
in a Go or PHP backend, and you get the same bytes.
|
||||
DiceBear 10 ships as native libraries for JavaScript, PHP, Python, Rust, Go, and
|
||||
Dart. Every port passes a shared test suite that requires byte-identical SVG
|
||||
output to the JavaScript reference. Generate an avatar in the browser,
|
||||
regenerate it later in a Go or PHP backend, and you get the same bytes.
|
||||
|
||||
| Language | Package | Install |
|
||||
| ----------------------- | ----------------------------------------------------------------------- | -------------------------------------------- |
|
||||
@@ -27,6 +27,7 @@ in a Go or PHP backend, and you get the same bytes.
|
||||
| Python | [`dicebear-core`](https://pypi.org/project/dicebear-core/) | `pip install dicebear-core` |
|
||||
| Rust | [`dicebear-core`](https://crates.io/crates/dicebear-core) | `cargo add dicebear-core` |
|
||||
| Go | [`dicebear-go`](https://pkg.go.dev/github.com/dicebear/dicebear-go/v10) | `go get github.com/dicebear/dicebear-go/v10` |
|
||||
| Dart | [`dicebear_core`](https://pub.dev/packages/dicebear_core) | `dart pub add dicebear_core` |
|
||||
|
||||
In JavaScript it looks like this; the
|
||||
[documentation](https://www.dicebear.com/introduction) has the equivalent for
|
||||
@@ -66,7 +67,7 @@ with Figma or from scratch.
|
||||
|
||||
## This repository
|
||||
|
||||
This monorepo contains the five core libraries, the CLI, the SVG-to-raster
|
||||
This monorepo contains the six core libraries, the CLI, the SVG-to-raster
|
||||
converter, the documentation site ([dicebear.com](https://www.dicebear.com)),
|
||||
and the editor. Related projects live in their own repositories:
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ export default defineConfig<ThemeOptions>({
|
||||
operatingSystem: 'Any',
|
||||
url: 'https://www.dicebear.com',
|
||||
description:
|
||||
'Privacy-focused, open source SVG avatar library with 35+ styles. Free Avatar API, JavaScript library, PHP library, Python library, Rust library, Go library, and CLI for generating deterministic profile pictures and user placeholder images.',
|
||||
'Privacy-focused, open source SVG avatar library with 35+ styles. Free Avatar API, JavaScript library, PHP library, Python library, Rust library, Go library, Dart library, and CLI for generating deterministic profile pictures and user placeholder images.',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
|
||||
@@ -36,6 +36,10 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
text: 'Go Library <span class="vp-sidebar-badge is-new">New</span>',
|
||||
link: '/how-to-use/go-library/',
|
||||
},
|
||||
{
|
||||
text: 'Dart Library <span class="vp-sidebar-badge is-new">New</span>',
|
||||
link: '/how-to-use/dart-library/',
|
||||
},
|
||||
{ text: 'HTTP-API', link: '/how-to-use/http-api/' },
|
||||
{ text: 'CLI', link: '/how-to-use/cli/' },
|
||||
],
|
||||
|
||||
@@ -17,7 +17,7 @@ import Button from 'primevue/button';
|
||||
<span class="app-hero-underline">35+ avatar styles</span>
|
||||
crafted by talented artists. Generate deterministic profile pictures via
|
||||
API, JS library, PHP library, Python library,
|
||||
Rust library, Go library & CLI.
|
||||
Rust library, Go library, Dart library & CLI.
|
||||
</p>
|
||||
|
||||
<div class="app-hero-actions">
|
||||
|
||||
@@ -67,13 +67,13 @@ const highlights = [
|
||||
color: '#22c55e',
|
||||
},
|
||||
{
|
||||
// One box for the language libraries (JS / PHP / Python / Rust / Go).
|
||||
// One box for the language libraries (JS / PHP / Python / Rust / Go / Dart).
|
||||
// Generic Library icon — no language logos — so the named languages stay
|
||||
// pure nominative use with no trademark/logo-modification questions.
|
||||
icon: Library,
|
||||
title: 'Official Libraries',
|
||||
description:
|
||||
'JavaScript, PHP, Python, Rust, and Go — one identical API across every language, same seed, same result, and no data leaves your servers.',
|
||||
'JavaScript, PHP, Python, Rust, Go, and Dart — one identical API across every language, same seed, same result, and no data leaves your servers.',
|
||||
color: '#f59e0b',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -68,6 +68,11 @@ let svg = Avatar::new(&style, json!({ "seed": "Mia" }))?.to_svg();`,
|
||||
style, _ := dicebear.NewStyle([]byte(styles.Lorelei))
|
||||
avatar, _ := dicebear.NewAvatar(style, map[string]any{"seed": "Mia"})
|
||||
svg := avatar.SVG()`,
|
||||
dart: `import 'package:dicebear_core/dicebear_core.dart';
|
||||
import 'package:dicebear_styles/lorelei.dart';
|
||||
|
||||
final style = Style.parse(lorelei);
|
||||
final svg = Avatar(style, {'seed': 'Mia'}).svg;`,
|
||||
api: `https://api.dicebear.com/10.x/lorelei/svg?seed=Mia`,
|
||||
cli: `npx dicebear lorelei --seed "Mia" --format svg`,
|
||||
};
|
||||
@@ -98,7 +103,7 @@ svg := avatar.SVG()`,
|
||||
<h3 class="app-integration-title">Libraries</h3>
|
||||
<p class="app-integration-description">
|
||||
Run DiceBear entirely in your own code — no data leaves your
|
||||
servers. JavaScript, PHP, Python, Rust, and Go share one
|
||||
servers. JavaScript, PHP, Python, Rust, Go, and Dart share one
|
||||
identical API.
|
||||
</p>
|
||||
</div>
|
||||
@@ -110,6 +115,7 @@ svg := avatar.SVG()`,
|
||||
<Tab value="python">Python</Tab>
|
||||
<Tab value="rust">Rust</Tab>
|
||||
<Tab value="go">Go</Tab>
|
||||
<Tab value="dart">Dart</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel value="js" class="app-integration-tabpanel">
|
||||
@@ -202,6 +208,24 @@ svg := avatar.SVG()`,
|
||||
<ArrowRight :size="18" />
|
||||
</Button>
|
||||
</TabPanel>
|
||||
<TabPanel value="dart" class="app-integration-tabpanel">
|
||||
<UiCode
|
||||
:code="plainCode.dart"
|
||||
lang="dart"
|
||||
scroll-to-bottom
|
||||
class="app-integration-code-block"
|
||||
/>
|
||||
<Button
|
||||
as="a"
|
||||
href="/how-to-use/dart-library/"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
class="app-integration-link"
|
||||
>
|
||||
Dart Documentation
|
||||
<ArrowRight :size="18" />
|
||||
</Button>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</UiCard>
|
||||
|
||||
@@ -5,8 +5,17 @@ import Button from 'primevue/button';
|
||||
import Select from 'primevue/select';
|
||||
import { UiCode } from '../ui';
|
||||
import { escapeJsString, escapeShellArg } from '../../utils/escape';
|
||||
import { formatDartValue } from '@theme/utils/code-examples';
|
||||
|
||||
type CodeExample = 'api' | 'js' | 'php' | 'python' | 'rust' | 'go' | 'cli';
|
||||
type CodeExample =
|
||||
| 'api'
|
||||
| 'js'
|
||||
| 'php'
|
||||
| 'python'
|
||||
| 'rust'
|
||||
| 'go'
|
||||
| 'dart'
|
||||
| 'cli';
|
||||
|
||||
const props = defineProps<{
|
||||
seed: string;
|
||||
@@ -22,6 +31,7 @@ const exampleOptions: { label: string; value: CodeExample }[] = [
|
||||
{ label: 'Python Library', value: 'python' },
|
||||
{ label: 'Rust Library', value: 'rust' },
|
||||
{ label: 'Go Library', value: 'go' },
|
||||
{ label: 'Dart Library', value: 'dart' },
|
||||
{ label: 'CLI', value: 'cli' },
|
||||
];
|
||||
|
||||
@@ -105,6 +115,19 @@ avatar, _ := dicebear.NewAvatar(style, map[string]any{
|
||||
})`;
|
||||
});
|
||||
|
||||
const dartExample = computed(
|
||||
() =>
|
||||
// formatDartValue quotes the seed and escapes $, which escapeJsString
|
||||
// leaves alone but Dart would treat as string interpolation.
|
||||
`import 'package:dicebear_core/dicebear_core.dart';
|
||||
import 'package:dicebear_styles/${props.style.replace(/-/g, '_')}.dart';
|
||||
|
||||
final style = Style.parse(${props.styleCamel});
|
||||
final avatar = Avatar(style, {
|
||||
'seed': ${formatDartValue(props.seed)},
|
||||
});`,
|
||||
);
|
||||
|
||||
const cliExample = computed(
|
||||
() => `npx dicebear ${props.style} --seed '${escapeShellArg(props.seed)}'`,
|
||||
);
|
||||
@@ -168,6 +191,13 @@ const playgroundLink = '/playground/';
|
||||
class="app-seed-demo-code-block"
|
||||
:class="{ active: activeExample === 'go' }"
|
||||
/>
|
||||
<UiCode
|
||||
:code="dartExample"
|
||||
lang="dart"
|
||||
scroll-to-bottom
|
||||
class="app-seed-demo-code-block"
|
||||
:class="{ active: activeExample === 'dart' }"
|
||||
/>
|
||||
<UiCode
|
||||
:code="cliExample"
|
||||
scroll-to-bottom
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
formatPhpValue,
|
||||
formatPythonValue,
|
||||
formatGoValue,
|
||||
formatDartValue,
|
||||
} from '@theme/utils/code-examples';
|
||||
import Button from 'primevue/button';
|
||||
import PlaygroundLicenseAlert from './PlaygroundLicenseAlert.vue';
|
||||
@@ -221,6 +222,40 @@ avatar, _ := dicebear.NewAvatar(style, ${goOptions})
|
||||
svg := avatar.SVG()`;
|
||||
});
|
||||
|
||||
const exampleDart = computed(() => {
|
||||
const dartOptions = formatDartValue(options.value, 1);
|
||||
|
||||
if (store.isCustomStyle) {
|
||||
return `import 'dart:io';
|
||||
|
||||
import 'package:dicebear_core/dicebear_core.dart';
|
||||
|
||||
// Your custom style definition (raw JSON)
|
||||
final style = Style.parse(File('./my-style.json').readAsStringSync());
|
||||
final avatar = Avatar(style, ${dartOptions});
|
||||
|
||||
final svg = avatar.svg;`;
|
||||
}
|
||||
|
||||
// The Dart styles package exposes each style as its own library with a
|
||||
// camelCase string constant (e.g. "big-ears" → big_ears.dart / bigEars).
|
||||
const styleLibrary = store.avatarStyleName.replace(/-/g, '_');
|
||||
const styleConst = store.avatarStyleName
|
||||
.split('-')
|
||||
.map((part, i) =>
|
||||
i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1),
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `import 'package:dicebear_core/dicebear_core.dart';
|
||||
import 'package:dicebear_styles/${styleLibrary}.dart';
|
||||
|
||||
final style = Style.parse(${styleConst});
|
||||
final avatar = Avatar(style, ${dartOptions});
|
||||
|
||||
final svg = avatar.svg;`;
|
||||
});
|
||||
|
||||
const exampleCli = computed(() =>
|
||||
getAvatarApiCommand(
|
||||
store.isCustomStyle ? './my-style.json' : store.avatarStyleName,
|
||||
@@ -247,6 +282,7 @@ const exampleCli = computed(() =>
|
||||
<Tab value="python-library">Python</Tab>
|
||||
<Tab value="rust-library">Rust</Tab>
|
||||
<Tab value="go-library">Go</Tab>
|
||||
<Tab value="dart-library">Dart</Tab>
|
||||
<Tab value="cli">CLI</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
@@ -373,6 +409,28 @@ const exampleCli = computed(() =>
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel value="dart-library">
|
||||
<div class="playground-button-how-to-use-tab-content">
|
||||
<p>First add the required packages with dart pub:</p>
|
||||
<UiCode
|
||||
:code="
|
||||
store.isCustomStyle
|
||||
? 'dart pub add dicebear_core'
|
||||
: 'dart pub add dicebear_core dicebear_styles'
|
||||
"
|
||||
/>
|
||||
<p>Then you can create this avatar as follows:</p>
|
||||
<UiCode :code="exampleDart" lang="dart" />
|
||||
<p v-if="store.isCustomStyle">
|
||||
Replace <code>./my-style.json</code> with the path to your
|
||||
style definition.
|
||||
</p>
|
||||
<p>
|
||||
See <a href="/how-to-use/dart-library">Dart</a> docs for more
|
||||
information.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel value="cli">
|
||||
<div class="playground-button-how-to-use-tab-content">
|
||||
<p>First install the CLI package via npm:</p>
|
||||
|
||||
@@ -47,6 +47,12 @@ const tabs = computed<Tab[]>(() => {
|
||||
code: examples.value.rust,
|
||||
});
|
||||
list.push({ key: 'go', label: 'Go', lang: 'go', code: examples.value.go });
|
||||
list.push({
|
||||
key: 'dart',
|
||||
label: 'Dart',
|
||||
lang: 'dart',
|
||||
code: examples.value.dart,
|
||||
});
|
||||
list.push({ key: 'cli', label: 'CLI', code: examples.value.cli });
|
||||
return list;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { kebabCase, constantCase, pascalCase } from 'change-case';
|
||||
import {
|
||||
kebabCase,
|
||||
constantCase,
|
||||
pascalCase,
|
||||
snakeCase,
|
||||
camelCase,
|
||||
} from 'change-case';
|
||||
import { UiCard, UiCode as Code } from '../ui';
|
||||
import { computed, ref } from 'vue';
|
||||
import Tabs from 'primevue/tabs';
|
||||
@@ -80,6 +86,23 @@ svg := avatar.SVG()
|
||||
`;
|
||||
});
|
||||
|
||||
const exampleDartInstall = computed(() => {
|
||||
return `dart pub add dicebear_core dicebear_styles`;
|
||||
});
|
||||
|
||||
const exampleDartUsage = computed(() => {
|
||||
return `import 'package:dicebear_core/dicebear_core.dart';
|
||||
import 'package:dicebear_styles/${snakeCase(props.styleName)}.dart';
|
||||
|
||||
final style = Style.parse(${camelCase(props.styleName)});
|
||||
final avatar = Avatar(style, {
|
||||
// ... options
|
||||
});
|
||||
|
||||
final svg = avatar.svg;
|
||||
`;
|
||||
});
|
||||
|
||||
const exampleCliInstall = computed(() => {
|
||||
return `npm install --global dicebear`;
|
||||
});
|
||||
@@ -145,6 +168,7 @@ const exampleCliUsage = computed(() => {
|
||||
<Tab value="python-library">Python</Tab>
|
||||
<Tab value="rust-library">Rust</Tab>
|
||||
<Tab value="go-library">Go</Tab>
|
||||
<Tab value="dart-library">Dart</Tab>
|
||||
<Tab value="cli">CLI</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
@@ -214,6 +238,17 @@ const exampleCliUsage = computed(() => {
|
||||
information.
|
||||
</p>
|
||||
</TabPanel>
|
||||
<TabPanel value="dart-library" class="style-usage-body">
|
||||
<p>First add the required packages with dart pub:</p>
|
||||
<Code :code="exampleDartInstall" />
|
||||
|
||||
<p>Then you can create this avatar as follows:</p>
|
||||
<Code lang="dart" :code="exampleDartUsage" />
|
||||
<p>
|
||||
See <a href="/how-to-use/dart-library">Dart</a> docs for more
|
||||
information.
|
||||
</p>
|
||||
</TabPanel>
|
||||
<TabPanel value="cli" class="style-usage-body">
|
||||
<p>First install the CLI package via npm:</p>
|
||||
<Code :code="exampleCliInstall" />
|
||||
|
||||
@@ -130,7 +130,7 @@ export function buildComparisonRows({
|
||||
{
|
||||
feature: 'Languages',
|
||||
values: {
|
||||
dicebear: 'JS/TS, PHP, Python, Rust, Go',
|
||||
dicebear: 'JS/TS, PHP, Python, Rust, Go, Dart',
|
||||
boringAvatars: 'JS',
|
||||
avvvatars: 'JS/TS',
|
||||
multiavatar: 'JS, PHP, Python',
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface CodeExamples {
|
||||
python: string;
|
||||
rust: string;
|
||||
go: string;
|
||||
dart: string;
|
||||
cli: string;
|
||||
}
|
||||
|
||||
@@ -137,6 +138,49 @@ export function formatGoValue(value: unknown, depth = 0): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function formatDartValue(value: unknown, depth = 0): string {
|
||||
const indent = ' '.repeat(depth);
|
||||
const outerIndent = depth > 0 ? ' '.repeat(depth - 1) : '';
|
||||
|
||||
if (value === null || value === undefined) return 'null';
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||
if (typeof value === 'number') return String(value);
|
||||
if (typeof value === 'string')
|
||||
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\$/g, '\\$')}'`;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return '[]';
|
||||
|
||||
if (depth === 0) {
|
||||
return `[${value.map((v) => formatDartValue(v)).join(', ')}]`;
|
||||
}
|
||||
|
||||
const items = value.map((v) => `${indent}${formatDartValue(v, depth + 1)}`);
|
||||
|
||||
// dart format keeps a trailing comma when the closing bracket is on its
|
||||
// own line.
|
||||
return `[\n${items.join(',\n')},\n${outerIndent}]`;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 0) return '{}';
|
||||
|
||||
if (depth === 0) {
|
||||
return `{${entries.map(([k, v]) => `'${k.replace(/'/g, "\\'")}': ${formatDartValue(v)}`).join(', ')}}`;
|
||||
}
|
||||
|
||||
const items = entries.map(
|
||||
([k, v]) =>
|
||||
`${indent}'${k.replace(/'/g, "\\'")}': ${formatDartValue(v, depth + 1)}`,
|
||||
);
|
||||
|
||||
return `{\n${items.join(',\n')},\n${outerIndent}}`;
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function generateCodeExamples(
|
||||
styleName: string,
|
||||
optionName: string,
|
||||
@@ -154,7 +198,9 @@ export function generateCodeExamples(
|
||||
|
||||
const go = `dicebear.NewAvatar(style, map[string]any{\n\t"${optionName}": ${formatGoValue(value)},\n})`;
|
||||
|
||||
const dart = `Avatar(style, {\n '${optionName}': ${formatDartValue(value)},\n});`;
|
||||
|
||||
const cli = getAvatarApiCommand(styleName, { [optionName]: value });
|
||||
|
||||
return { httpApi, js, php, python, rust, go, cli };
|
||||
return { httpApi, js, php, python, rust, go, dart, cli };
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export function loadHljs(): Promise<Hljs> {
|
||||
{ default: python },
|
||||
{ default: rust },
|
||||
{ default: go },
|
||||
{ default: dart },
|
||||
] = await Promise.all([
|
||||
import('highlight.js/lib/core'),
|
||||
import('highlight.js/lib/languages/javascript'),
|
||||
@@ -20,6 +21,7 @@ export function loadHljs(): Promise<Hljs> {
|
||||
import('highlight.js/lib/languages/python'),
|
||||
import('highlight.js/lib/languages/rust'),
|
||||
import('highlight.js/lib/languages/go'),
|
||||
import('highlight.js/lib/languages/dart'),
|
||||
]);
|
||||
|
||||
for (const [name, language] of [
|
||||
@@ -29,6 +31,7 @@ export function loadHljs(): Promise<Hljs> {
|
||||
['python', python],
|
||||
['rust', rust],
|
||||
['go', go],
|
||||
['dart', dart],
|
||||
] as const) {
|
||||
core.registerLanguage(name, language);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,20 @@ descriptor := dicebear.NewOptionsDescriptor(style).ToJSON()
|
||||
fmt.Println(descriptor)
|
||||
```
|
||||
|
||||
## Dart
|
||||
|
||||
```dart
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dicebear_core/dicebear_core.dart';
|
||||
import 'package:dicebear_styles/micah.dart';
|
||||
|
||||
final style = Style.parse(micah);
|
||||
final descriptor = OptionsDescriptor(style);
|
||||
|
||||
print(jsonEncode(descriptor.toJson()));
|
||||
```
|
||||
|
||||
## Field descriptor types
|
||||
|
||||
The `toJSON()` method returns a map of option names to field descriptors. Each
|
||||
|
||||
@@ -24,7 +24,7 @@ workflow there is not the usual "edit a JSON file" loop.
|
||||
|
||||
## Core library, CLI, documentation, editor
|
||||
|
||||
The JavaScript, PHP, Python, Rust and Go cores, the CLI, the VitePress
|
||||
The JavaScript, PHP, Python, Rust, Go and Dart cores, the CLI, the VitePress
|
||||
documentation (including the Playground), and the standalone editor all live in
|
||||
the main [`dicebear/dicebear`](https://github.com/dicebear/dicebear) monorepo.
|
||||
See:
|
||||
@@ -33,7 +33,8 @@ See:
|
||||
in `dicebear/dicebear`
|
||||
|
||||
It covers the monorepo layout, per-package workflow, cross-language parity tests
|
||||
across the JavaScript, PHP, Python, Rust and Go cores, and the release process.
|
||||
across the JavaScript, PHP, Python, Rust, Go and Dart cores, and the release
|
||||
process.
|
||||
|
||||
## JSON Schema
|
||||
|
||||
|
||||
@@ -402,6 +402,19 @@ avatar, _ := dicebear.NewAvatar(style, map[string]any{"seed": "test"})
|
||||
fmt.Println(avatar.SVG())
|
||||
```
|
||||
|
||||
### With the Dart Library
|
||||
|
||||
```dart
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dicebear_core/dicebear_core.dart';
|
||||
|
||||
final style = Style.parse(File('./my-style.json').readAsStringSync());
|
||||
|
||||
final avatar = Avatar(style, {'seed': 'test'});
|
||||
print(avatar.svg);
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
- See the [Definition Schema Reference](/specification/definition-schema/) for
|
||||
|
||||
@@ -112,7 +112,8 @@ Congratulations! You can now use your avatar style with the
|
||||
[PHP Library](/how-to-use/php-library/), the
|
||||
[Python Library](/how-to-use/python-library/), the
|
||||
[Rust Library](/how-to-use/rust-library/), the
|
||||
[Go Library](/how-to-use/go-library/), or the [CLI](/how-to-use/cli/).
|
||||
[Go Library](/how-to-use/go-library/), the
|
||||
[Dart Library](/how-to-use/dart-library/), or the [CLI](/how-to-use/cli/).
|
||||
|
||||
### With the JS Library
|
||||
|
||||
@@ -189,6 +190,21 @@ avatar, _ := dicebear.NewAvatar(style, map[string]any{
|
||||
})
|
||||
```
|
||||
|
||||
### With the Dart Library
|
||||
|
||||
```dart
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dicebear_core/dicebear_core.dart';
|
||||
|
||||
final style = Style.parse(File('./your-style.json').readAsStringSync());
|
||||
|
||||
final avatar = Avatar(style, {
|
||||
'seed': 'dicebear',
|
||||
// ... other options
|
||||
});
|
||||
```
|
||||
|
||||
### With the CLI
|
||||
|
||||
```
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Load All Avatar Styles from @dicebear/styles | DiceBear
|
||||
description: >
|
||||
Learn how to load every avatar style shipped with @dicebear/styles at once in
|
||||
Node.js, PHP, Python, Rust and Go.
|
||||
Node.js, PHP, Python, Rust, Go and Dart.
|
||||
---
|
||||
|
||||
# How to load all avatar styles from `@dicebear/styles`?
|
||||
@@ -12,11 +12,15 @@ official avatar style as a separate JSON file. It is distributed as
|
||||
[`@dicebear/styles`](https://www.npmjs.com/package/@dicebear/styles) on npm,
|
||||
[`dicebear/styles`](https://packagist.org/packages/dicebear/styles) on
|
||||
Packagist, [`dicebear-styles`](https://pypi.org/project/dicebear-styles/) on
|
||||
PyPI and [`dicebear-styles`](https://crates.io/crates/dicebear-styles) on
|
||||
crates.io. Most projects only need one or two styles, but sometimes (for a style
|
||||
picker, a gallery page, or a batch job) you want to load all of them at once.
|
||||
PyPI, [`dicebear-styles`](https://crates.io/crates/dicebear-styles) on
|
||||
crates.io,
|
||||
[`github.com/dicebear/styles/v10`](https://pkg.go.dev/github.com/dicebear/styles/v10)
|
||||
as a Go module and [`dicebear_styles`](https://pub.dev/packages/dicebear_styles)
|
||||
on pub.dev. Most projects only need one or two styles, but sometimes (for a
|
||||
style picker, a gallery page, or a batch job) you want to load all of them at
|
||||
once.
|
||||
|
||||
This guide shows how to do that in Node.js, PHP, Python, Rust and Go.
|
||||
This guide shows how to do that in Node.js, PHP, Python, Rust, Go and Dart.
|
||||
|
||||
## Node.js
|
||||
|
||||
@@ -163,3 +167,28 @@ for _, name := range styles.All() {
|
||||
|
||||
avatar, _ := dicebear.NewAvatar(parsed["lorelei"], map[string]any{"seed": "Alice"})
|
||||
```
|
||||
|
||||
## Dart
|
||||
|
||||
The `dicebear_styles` package ships each style in its own library, so a compiled
|
||||
app only embeds the styles it imports. To load _all_ of them, import the
|
||||
umbrella library `package:dicebear_styles/dicebear_styles.dart`, which
|
||||
re-exports every style:
|
||||
|
||||
```sh
|
||||
dart pub add dicebear_core dicebear_styles
|
||||
```
|
||||
|
||||
`styles.all` lists every embedded style and `styles.get(name)` returns its raw
|
||||
JSON definition.
|
||||
|
||||
```dart
|
||||
import 'package:dicebear_core/dicebear_core.dart';
|
||||
import 'package:dicebear_styles/dicebear_styles.dart' as styles;
|
||||
|
||||
final parsed = {
|
||||
for (final name in styles.all) name: Style.parse(styles.get(name)!),
|
||||
};
|
||||
|
||||
final avatar = Avatar(parsed['lorelei']!, {'seed': 'Alice'});
|
||||
```
|
||||
|
||||
@@ -255,6 +255,27 @@ func placeholderAvatar(style *dicebear.Style, userID string) (string, error) {
|
||||
}
|
||||
```
|
||||
|
||||
## With the Dart library
|
||||
|
||||
Use the Dart library for server-side rendering without an additional HTTP
|
||||
request. For full installation and API details, see the
|
||||
[Dart library documentation](/how-to-use/dart-library/).
|
||||
|
||||
```dart
|
||||
import 'package:dicebear_core/dicebear_core.dart';
|
||||
import 'package:dicebear_styles/thumbs.dart';
|
||||
|
||||
final style = Style.parse(thumbs);
|
||||
|
||||
String getPlaceholderAvatar(String userId) {
|
||||
return Avatar(style, {
|
||||
'seed': userId,
|
||||
'size': 48,
|
||||
'borderRadius': 50,
|
||||
}).svg;
|
||||
}
|
||||
```
|
||||
|
||||
## Choosing a style
|
||||
|
||||
Different styles suit different use cases. Click a style to see all available
|
||||
@@ -291,6 +312,11 @@ Avatar::new(&style, json!({ "seed": user_id, "size": 48, "borderRadius": 50 }))?
|
||||
dicebear.NewAvatar(style, map[string]any{"seed": userID, "size": 48, "borderRadius": 50})
|
||||
```
|
||||
|
||||
```dart
|
||||
// Dart library
|
||||
Avatar(style, {'seed': userId, 'size': 48, 'borderRadius': 50});
|
||||
```
|
||||
|
||||
```
|
||||
// HTTP API
|
||||
https://api.dicebear.com/10.x/thumbs/svg?seed=user-123&size=48&borderRadius=50
|
||||
|
||||
@@ -83,6 +83,18 @@ gravatarImage := fmt.Sprintf("https://www.gravatar.com/avatar/%s?d=%s", emailHas
|
||||
// https://www.gravatar.com/avatar/00000000000000000000000000000000?d=https%3A%2F%2Fapi.dicebear.com%2F10.x%2Florelei%2Fpng
|
||||
```
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
```dart [Dart]
|
||||
final emailHash = Uri.encodeComponent('00000000000000000000000000000000');
|
||||
final defaultImage = Uri.encodeComponent(
|
||||
'https://api.dicebear.com/10.x/lorelei/svg' // [!code --]
|
||||
'https://api.dicebear.com/10.x/lorelei/png' // [!code ++]
|
||||
);
|
||||
|
||||
final gravatarImage = 'https://www.gravatar.com/avatar/$emailHash?d=$defaultImage';
|
||||
// https://www.gravatar.com/avatar/00000000000000000000000000000000?d=https%3A%2F%2Fapi.dicebear.com%2F10.x%2Florelei%2Fpng
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
Usually we set options in the query string, such as the seed. Since a query
|
||||
@@ -154,4 +166,17 @@ gravatarImage := fmt.Sprintf("https://www.gravatar.com/avatar/%s?d=%s", emailHas
|
||||
// https://www.gravatar.com/avatar/00000000000000000000000000000000?d=https%3A%2F%2Fapi.dicebear.com%2F10.x%2Florelei%2Fpng%2Fseed%253D00000000000000000000000000000000
|
||||
```
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
```dart [Dart]
|
||||
final emailHash = Uri.encodeComponent('00000000000000000000000000000000');
|
||||
final options = 'seed=$emailHash';
|
||||
final defaultImage = Uri.encodeComponent(
|
||||
'https://api.dicebear.com/10.x/lorelei/png?$options' // [!code --]
|
||||
'https://api.dicebear.com/10.x/lorelei/png/${Uri.encodeComponent(options)}' // [!code ++]
|
||||
);
|
||||
|
||||
final gravatarImage = 'https://www.gravatar.com/avatar/$emailHash?d=$defaultImage';
|
||||
// https://www.gravatar.com/avatar/00000000000000000000000000000000?d=https%3A%2F%2Fapi.dicebear.com%2F10.x%2Florelei%2Fpng%2Fseed%253D00000000000000000000000000000000
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
---
|
||||
title: Dart Avatar Library | DiceBear
|
||||
description: >
|
||||
Use the DiceBear Dart library to generate SVG profile pictures in Dart and
|
||||
Flutter. Dart 3.4+ with an API identical to the JavaScript library.
|
||||
---
|
||||
|
||||
# Dart avatar library
|
||||
|
||||
The Dart library provides an API identical to the
|
||||
[JavaScript library](/how-to-use/js-library/). It requires Dart 3.4 or higher
|
||||
and also works in Flutter apps. The same seed and style definition produce SVGs
|
||||
byte-identical to the JavaScript reference.
|
||||
|
||||
## Installation
|
||||
|
||||
You need two packages: the core library `dicebear_core` and the avatar style
|
||||
definitions `dicebear_styles`. Each style is a string constant in its own
|
||||
library, so a compiled app only embeds the styles it imports.
|
||||
|
||||
```sh
|
||||
dart pub add dicebear_core
|
||||
dart pub add dicebear_styles
|
||||
```
|
||||
|
||||
In a Flutter project, use `flutter pub add` instead.
|
||||
|
||||
## Usage
|
||||
|
||||
We use the avatar style [lorelei](/styles/lorelei/) in our example. You can find
|
||||
more avatar styles [here](/styles/). Each style is exposed as a raw-JSON string
|
||||
(e.g. `lorelei` from `package:dicebear_styles/lorelei.dart`) that you hand to
|
||||
`Style.parse`.
|
||||
|
||||
```dart
|
||||
import 'package:dicebear_core/dicebear_core.dart';
|
||||
import 'package:dicebear_styles/lorelei.dart';
|
||||
|
||||
void main() {
|
||||
final style = Style.parse(lorelei);
|
||||
|
||||
final avatar = Avatar(style, {
|
||||
'seed': 'John',
|
||||
// ... other options
|
||||
});
|
||||
|
||||
print(avatar.svg);
|
||||
}
|
||||
```
|
||||
|
||||
`Style.parse` decodes and validates the raw JSON string. If you already hold a
|
||||
decoded definition (a `Map<String, Object?>`), pass it to the default
|
||||
`Style(...)` constructor instead.
|
||||
|
||||
Each avatar style comes with several options. You can find them on the details
|
||||
page of each [avatar style](/styles/).
|
||||
|
||||
:::info
|
||||
|
||||
We provide a large number of avatar styles from different artists. The avatar
|
||||
styles are licensed under different licenses that the artists can choose
|
||||
themselves. For a quick overview we have created an
|
||||
[license overview](/licenses/) for you.
|
||||
|
||||
:::
|
||||
|
||||
## Deterministic avatars
|
||||
|
||||
The `seed` option is the key to generating deterministic avatars. The same seed
|
||||
always produces the same avatar:
|
||||
|
||||
```dart
|
||||
final avatar1 = Avatar(style, {'seed': 'user-123'});
|
||||
final avatar2 = Avatar(style, {'seed': 'user-123'});
|
||||
|
||||
// avatar1.svg == avatar2.svg
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
### `Style`
|
||||
|
||||
A validated, immutable wrapper around a style definition. Build it once from the
|
||||
decoded definition JSON, then reuse it when generating multiple avatars. Invalid
|
||||
definitions throw a `StyleValidationError`.
|
||||
|
||||
```dart
|
||||
final style = Style.parse(lorelei);
|
||||
|
||||
final avatar1 = Avatar(style, {'seed': 'Alice'});
|
||||
final avatar2 = Avatar(style, {'seed': 'Bob'});
|
||||
```
|
||||
|
||||
### `Avatar`
|
||||
|
||||
The main class for generating avatars. The constructor takes a `Style` and an
|
||||
optional map of options (invalid options throw an `OptionsValidationError`,
|
||||
circular color references a `CircularColorReferenceError`). Omitting the options
|
||||
map is the same as passing an empty one.
|
||||
|
||||
```dart
|
||||
final avatar = Avatar(style, {
|
||||
// ... options
|
||||
});
|
||||
```
|
||||
|
||||
### `OptionsDescriptor`
|
||||
|
||||
Describes all valid options for a given style. Useful for building UIs or
|
||||
validating user input.
|
||||
|
||||
```dart
|
||||
final descriptor = OptionsDescriptor(style).toJson();
|
||||
```
|
||||
|
||||
## Methods
|
||||
|
||||
### `svg` / `toString()`
|
||||
|
||||
**Return type:** `String`
|
||||
|
||||
Returns the avatar as SVG in XML format. `toString()` returns the same string,
|
||||
so an `Avatar` can be used directly in string contexts (string interpolation,
|
||||
`print`).
|
||||
|
||||
```dart
|
||||
final avatar = Avatar(style, {'seed': 'Alice'});
|
||||
|
||||
var svg = avatar.svg;
|
||||
// or
|
||||
svg = avatar.toString();
|
||||
```
|
||||
|
||||
### `toJson()`
|
||||
|
||||
**Return type:** `Map<String, Object?>` (with keys `svg` and `options`)
|
||||
|
||||
Returns the SVG and the resolved options as a JSON-encodable map. Pass it to
|
||||
`jsonEncode` for the serialized form.
|
||||
|
||||
```dart
|
||||
final avatar = Avatar(style, {'seed': 'Alice'});
|
||||
|
||||
final result = jsonEncode(avatar.toJson());
|
||||
|
||||
// result → {"svg":"<svg>...</svg>","options":{"flip":"none",...}}
|
||||
```
|
||||
|
||||
The resolved options are also available directly as a map via
|
||||
`avatar.resolvedOptions`.
|
||||
|
||||
### `toDataUri()`
|
||||
|
||||
**Return type:** `String`
|
||||
|
||||
Returns the avatar as [data URI](https://en.wikipedia.org/wiki/Data_URI_scheme).
|
||||
|
||||
```dart
|
||||
final avatar = Avatar(style, {'seed': 'Alice'});
|
||||
|
||||
final dataUri = avatar.toDataUri();
|
||||
|
||||
// <img src="{dataUri}" alt="Avatar" />
|
||||
```
|
||||
|
||||
## Core options
|
||||
|
||||
The core options are identical to the JavaScript library. See the
|
||||
[JS Library core options](/how-to-use/js-library/#core-options) for the full
|
||||
reference. Here are the options in Dart syntax:
|
||||
|
||||
```dart
|
||||
final avatar = Avatar(style, {
|
||||
'seed': 'Alice',
|
||||
'flip': 'horizontal', // 'none', 'horizontal', 'vertical', 'both'
|
||||
'rotate': 10, // -360 to 360, or [min, max] range
|
||||
'scale': 0.9, // 0 to 10 (1 = original), or [min, max] range
|
||||
'borderRadius': 50, // 0-50 (50 = circle)
|
||||
'size': 128,
|
||||
'translateX': 0, // -1000 to 1000 (percent of canvas width)
|
||||
'translateY': 0, // -1000 to 1000 (percent of canvas height)
|
||||
'idRandomization': true,
|
||||
'title': 'User Avatar',
|
||||
'fontFamily': 'Arial', // or ['Arial', 'Helvetica']
|
||||
'fontWeight': 700, // 1-1000
|
||||
'backgroundColor': ['#b6e3f4', '#c0aede'],
|
||||
'backgroundColorFill': 'solid', // 'solid', 'linear', 'radial'
|
||||
});
|
||||
```
|
||||
|
||||
Dynamic component and color options also work the same way. See the
|
||||
[JS Library documentation](/how-to-use/js-library/#dynamic-component-options)
|
||||
for all available patterns.
|
||||
|
||||
## Examples
|
||||
|
||||
### Rendering in Flutter
|
||||
|
||||
The library has no Flutter dependency; it returns plain strings. To display an
|
||||
avatar in a Flutter widget tree, render the SVG string with a package such as
|
||||
[`flutter_svg`](https://pub.dev/packages/flutter_svg):
|
||||
|
||||
```dart
|
||||
final avatar = Avatar(style, {'seed': 'Alice', 'size': 128});
|
||||
|
||||
// In your build method, with package:flutter_svg
|
||||
SvgPicture.string(avatar.svg, width: 128, height: 128);
|
||||
```
|
||||
|
||||
### Avatar with custom background
|
||||
|
||||
```dart
|
||||
final avatar = Avatar(style, {
|
||||
'seed': 'Alice',
|
||||
'backgroundColor': ['#b6e3f4', '#c0aede', '#d1d4f9'],
|
||||
});
|
||||
```
|
||||
|
||||
### Fixed size avatar
|
||||
|
||||
```dart
|
||||
import 'package:dicebear_styles/bottts.dart';
|
||||
|
||||
final style = Style.parse(bottts);
|
||||
|
||||
final avatar = Avatar(style, {
|
||||
'seed': 'robot-42',
|
||||
'size': 128,
|
||||
'borderRadius': 50, // circular avatar
|
||||
});
|
||||
```
|
||||
|
||||
### Avatar with transformations
|
||||
|
||||
```dart
|
||||
import 'package:dicebear_styles/avataaars.dart';
|
||||
|
||||
final style = Style.parse(avataaars);
|
||||
|
||||
final avatar = Avatar(style, {
|
||||
'seed': 'Jane',
|
||||
'flip': 'horizontal',
|
||||
'rotate': 10,
|
||||
'scale': 0.9,
|
||||
'translateY': 5,
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple avatars on the same page
|
||||
|
||||
When rendering multiple avatars on the same page, use `idRandomization` to
|
||||
prevent SVG ID conflicts:
|
||||
|
||||
```dart
|
||||
final style = Style.parse(lorelei);
|
||||
|
||||
for (final seed in ['alice', 'bob', 'charlie']) {
|
||||
final avatar = Avatar(style, {
|
||||
'seed': seed,
|
||||
'idRandomization': true,
|
||||
});
|
||||
print(avatar.svg);
|
||||
}
|
||||
```
|
||||
|
||||
### Weighted variant selection
|
||||
|
||||
A weighted map makes some variants more likely than others. The lorelei style
|
||||
selects `happy01` or `happy02` mouths twice as often as `sad01` here:
|
||||
|
||||
```dart
|
||||
final avatar = Avatar(style, {
|
||||
'seed': 'Alice',
|
||||
'mouthVariant': {'happy01': 2, 'happy02': 2, 'sad01': 1},
|
||||
});
|
||||
```
|
||||
@@ -15,8 +15,9 @@ higher). In other environments you may be interested in the
|
||||
[PHP Library](/how-to-use/php-library/), the
|
||||
[Python Library](/how-to-use/python-library/), the
|
||||
[Rust Library](/how-to-use/rust-library/), the
|
||||
[Go Library](/how-to-use/go-library/), the [HTTP API](/how-to-use/http-api/) or
|
||||
the [CLI](/how-to-use/cli/).
|
||||
[Go Library](/how-to-use/go-library/), the
|
||||
[Dart Library](/how-to-use/dart-library/), the [HTTP API](/how-to-use/http-api/)
|
||||
or the [CLI](/how-to-use/cli/).
|
||||
|
||||
The library is a pure
|
||||
[ESM package](https://developer.mozilla.org/en-US/Web/JavaScript/Guide/Modules).
|
||||
|
||||
@@ -3,12 +3,12 @@ title: DiceBear – Open Source Avatar Library & API
|
||||
description: >
|
||||
DiceBear is a free, open source avatar library and avatar API. Generate
|
||||
deterministic SVG profile pictures and user placeholder images via JavaScript
|
||||
library, PHP library, Python library, Rust library, Go library, HTTP API, or
|
||||
CLI.
|
||||
library, PHP library, Python library, Rust library, Go library, Dart library,
|
||||
HTTP API, or CLI.
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import { Palette, Code2, Globe, Terminal, Server, Library, Boxes, Hexagon } from '@lucide/vue';
|
||||
import { Palette, Code2, Globe, Terminal, Server, Library, Boxes, Hexagon, Target } from '@lucide/vue';
|
||||
import DocsHighlights from '@theme/components/docs/DocsHighlights.vue';
|
||||
|
||||
const highlights = [
|
||||
@@ -60,6 +60,14 @@ const highlights = [
|
||||
color: '#00add8',
|
||||
link: '/how-to-use/go-library/',
|
||||
},
|
||||
{
|
||||
icon: Target,
|
||||
title: 'Dart Library',
|
||||
description:
|
||||
'Generate avatars in Dart and Flutter apps with Dart 3.4+. Identical API to the JS library: same seed, same result.',
|
||||
color: '#0175c2',
|
||||
link: '/how-to-use/dart-library/',
|
||||
},
|
||||
{
|
||||
icon: Terminal,
|
||||
title: 'CLI',
|
||||
@@ -99,7 +107,8 @@ And thanks to the [JavaScript library](/how-to-use/js-library/),
|
||||
[PHP library](/how-to-use/php-library/),
|
||||
[Python library](/how-to-use/python-library/),
|
||||
[Rust library](/how-to-use/rust-library/),
|
||||
[Go library](/how-to-use/go-library/), [HTTP API](/how-to-use/http-api/),
|
||||
[Go library](/how-to-use/go-library/),
|
||||
[Dart library](/how-to-use/dart-library/), [HTTP API](/how-to-use/http-api/),
|
||||
[CLI](/how-to-use/cli/),
|
||||
[Figma plugin](https://www.figma.com/community/plugin/1005765655729342787/DiceBear-Exporter),
|
||||
[Editor](https://editor.dicebear.com) and [Playground](/playground/), your next
|
||||
@@ -132,9 +141,9 @@ DiceBear is built with privacy in mind. When using the
|
||||
[JavaScript library](/how-to-use/js-library/),
|
||||
[PHP library](/how-to-use/php-library/),
|
||||
[Python library](/how-to-use/python-library/),
|
||||
[Rust library](/how-to-use/rust-library/) or
|
||||
[Go library](/how-to-use/go-library/), avatars are generated entirely on your
|
||||
infrastructure. No personal data ever leaves your systems. For teams that
|
||||
[Rust library](/how-to-use/rust-library/), [Go library](/how-to-use/go-library/)
|
||||
or [Dart library](/how-to-use/dart-library/), avatars are generated entirely on
|
||||
your infrastructure. No personal data ever leaves your systems. For teams that
|
||||
require full control over data retention and infrastructure, DiceBear can be
|
||||
[self-hosted](/guides/host-the-http-api-yourself/), so there is no dependency on
|
||||
external services.
|
||||
|
||||
@@ -559,6 +559,7 @@ Install via your package manager:
|
||||
| PyPI | `pip install dicebear-schema` |
|
||||
| Cargo | `cargo add dicebear-schema` |
|
||||
| Go | `go get github.com/dicebear/schema` |
|
||||
| pub.dev | `dart pub add dicebear_schema` |
|
||||
|
||||
Or reference the schema directly from a CDN, handy for the `$schema` field of
|
||||
your style definition so editors like VS Code provide autocomplete and inline
|
||||
@@ -570,6 +571,11 @@ https://cdn.hopjs.net/npm/@dicebear/schema@1.0.0/dist/options.min.json
|
||||
```
|
||||
|
||||
The vendored style definitions shipped by DiceBear live in a separate package:
|
||||
[`@dicebear/styles`](https://www.npmjs.com/package/@dicebear/styles) on npm and
|
||||
[`@dicebear/styles`](https://www.npmjs.com/package/@dicebear/styles) on npm,
|
||||
[`dicebear/styles`](https://packagist.org/packages/dicebear/styles) on
|
||||
Packagist.
|
||||
Packagist, [`dicebear-styles`](https://pypi.org/project/dicebear-styles/) on
|
||||
PyPI, [`dicebear-styles`](https://crates.io/crates/dicebear-styles) on
|
||||
crates.io,
|
||||
[`github.com/dicebear/styles/v10`](https://pkg.go.dev/github.com/dicebear/styles/v10)
|
||||
as a Go module and [`dicebear_styles`](https://pub.dev/packages/dicebear_styles)
|
||||
on pub.dev.
|
||||
|
||||
@@ -12,8 +12,9 @@ A correct implementation produces **byte-identical SVGs** to the
|
||||
[JavaScript](https://github.com/dicebear/dicebear/tree/10.x/src/js/core),
|
||||
[PHP](https://github.com/dicebear/dicebear/tree/10.x/src/php/core),
|
||||
[Python](https://github.com/dicebear/dicebear/tree/10.x/src/python/core),
|
||||
[Rust](https://github.com/dicebear/dicebear/tree/10.x/src/rust/core) and
|
||||
[Go](https://github.com/dicebear/dicebear/tree/10.x/src/go/core) reference
|
||||
[Rust](https://github.com/dicebear/dicebear/tree/10.x/src/rust/core),
|
||||
[Go](https://github.com/dicebear/dicebear/tree/10.x/src/go/core) and
|
||||
[Dart](https://github.com/dicebear/dicebear/tree/10.x/src/dart/core) reference
|
||||
implementations for the same seed and style definition.
|
||||
|
||||
## Architecture overview
|
||||
@@ -623,8 +624,9 @@ The root `<svg>` element's attributes, in this order:
|
||||
|
||||
Its children, in this exact order:
|
||||
|
||||
1. The generator comment `<!-- Generated by DiceBear (https://dicebear.com) -->`,
|
||||
always present and byte-identical across implementations.
|
||||
1. The generator comment
|
||||
`<!-- Generated by DiceBear (https://dicebear.com) -->`, always present and
|
||||
byte-identical across implementations.
|
||||
2. `<metadata>`: the Dublin Core / RDF block from `meta` (see below); omitted
|
||||
entirely if `meta` is empty.
|
||||
3. `<defs>`: the accumulated definitions (clip path, gradients, component
|
||||
@@ -729,7 +731,7 @@ matches the first letter for every input the regex produces.
|
||||
The DiceBear repository ships a language-neutral parity test suite at
|
||||
[`tests/fixtures/parity/`](https://github.com/dicebear/dicebear/tree/10.x/tests/fixtures/parity).
|
||||
It is the canonical way to verify a new implementation: the JavaScript, PHP,
|
||||
Python, Rust, and Go reference implementations all consume the same JSON
|
||||
Python, Rust, Go, and Dart reference implementations all consume the same JSON
|
||||
fixtures and assert the same outputs, so any port that reads these fixtures gets
|
||||
the same coverage for free.
|
||||
|
||||
@@ -843,3 +845,4 @@ with multiple components and color constraints.
|
||||
| Python | `dicebear-core` | [src/python/core/src/](https://github.com/dicebear/dicebear/tree/10.x/src/python/core/src) |
|
||||
| Rust | `dicebear-core` | [src/rust/core/src/](https://github.com/dicebear/dicebear/tree/10.x/src/rust/core/src) |
|
||||
| Go | `github.com/dicebear/dicebear-go/v10` | [src/go/core/](https://github.com/dicebear/dicebear/tree/10.x/src/go/core) |
|
||||
| Dart | `dicebear_core` | [src/dart/core/lib/](https://github.com/dicebear/dicebear/tree/10.x/src/dart/core/lib) |
|
||||
|
||||
@@ -65,6 +65,22 @@ if (existsSync(cargoPath)) {
|
||||
}
|
||||
}
|
||||
|
||||
// The Dart core is not an npm workspace either; bump its pubspec.yaml so it
|
||||
// ships on the same version as the other ports. pub.dev's automated publishing
|
||||
// requires the pubspec version to match the v{{version}} tag exactly. Only the
|
||||
// top-level `version:` line (at column 0) matches; indented dependency
|
||||
// constraints do not.
|
||||
const pubspecPath = join(ROOT, "src/dart/core/pubspec.yaml");
|
||||
if (existsSync(pubspecPath)) {
|
||||
const raw = readFileSync(pubspecPath, "utf-8");
|
||||
const updated = raw.replace(/^version: .*$/m, `version: ${version}`);
|
||||
|
||||
if (updated !== raw) {
|
||||
writeFileSync(pubspecPath, updated);
|
||||
console.log(` dicebear_core (dart): → ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
// The Go core (src/go/core) needs no file bump: a Go module's version lives
|
||||
// entirely in the Git tag, which the module proxy reads directly. The tag
|
||||
// created below (e.g. v10.2.0) is mirrored to the standalone dicebear-go repo by
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Note: .pubignore replaces this file for `dart pub publish`, so entries here
|
||||
# never affect the package contents.
|
||||
.dart_tool
|
||||
pubspec.lock
|
||||
|
||||
# Build-time copy of the repository's CHANGELOG.md (pub.dev expects the file
|
||||
# inside the package directory) — created by the CI workflows before
|
||||
# `dart pub publish [--dry-run]`, like the LICENSE copies in the styles and
|
||||
# schema repositories.
|
||||
CHANGELOG.md
|
||||
@@ -0,0 +1,15 @@
|
||||
# A .pubignore replaces this directory's .gitignore for `dart pub publish`.
|
||||
# Unlike dicebear_schema/dicebear_styles, lib/ here is real committed code, so
|
||||
# the allowlist trick those packages use is not needed; this file only trims
|
||||
# the published archive.
|
||||
.dart_tool/
|
||||
pubspec.lock
|
||||
|
||||
# The tests read the cross-language parity fixtures from
|
||||
# ../../../tests/fixtures/parity, which does not exist in the published
|
||||
# package.
|
||||
test/
|
||||
|
||||
# Development-only: regenerates the embedded web fixtures from the monorepo's
|
||||
# parity fixtures, which the published package does not carry.
|
||||
tool/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Florian Körner
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,71 @@
|
||||
<h1><img src="https://www.dicebear.com/logo-readme.svg" width="28" /> DiceBear Core (Dart)</h1>
|
||||
|
||||
Dart implementation of the DiceBear avatar library. Generates deterministic SVG
|
||||
avatars from style definitions and a seed string.
|
||||
|
||||
DiceBear is available for multiple languages. All implementations share the same
|
||||
PRNG and rendering pipeline, producing identical SVG output for the same seed,
|
||||
style, and options, regardless of the language used.
|
||||
|
||||
[Playground](https://www.dicebear.com/playground) |
|
||||
[Documentation](https://www.dicebear.com/how-to-use/dart-library/)
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
dart pub add dicebear_core
|
||||
dart pub add dicebear_styles
|
||||
```
|
||||
|
||||
`dicebear_core` is the engine; the avatar style definitions ship separately in
|
||||
the pure-data [`dicebear_styles`](https://pub.dev/packages/dicebear_styles)
|
||||
package.
|
||||
|
||||
Requires Dart 3.4 or newer.
|
||||
|
||||
## Usage
|
||||
|
||||
```dart
|
||||
import 'package:dicebear_core/dicebear_core.dart';
|
||||
import 'package:dicebear_styles/adventurer.dart';
|
||||
|
||||
void main() {
|
||||
// Each style ships as a raw JSON string; Style.parse decodes and validates it.
|
||||
final style = Style.parse(adventurer);
|
||||
|
||||
final avatar = Avatar(style, {
|
||||
'seed': 'John Doe',
|
||||
'size': 128,
|
||||
});
|
||||
|
||||
print(avatar.svg); // SVG string
|
||||
print(avatar.toDataUri()); // data:image/svg+xml;charset=utf-8,...
|
||||
}
|
||||
```
|
||||
|
||||
### Using the Style type
|
||||
|
||||
```dart
|
||||
final style = Style.parse(adventurer);
|
||||
|
||||
// Create multiple avatars from the same style
|
||||
final avatar1 = Avatar(style, {'seed': 'Alice'});
|
||||
final avatar2 = Avatar(style, {'seed': 'Bob'});
|
||||
```
|
||||
|
||||
If you already hold a decoded definition (a `Map<String, Object?>`, for example
|
||||
one you built yourself or decoded with `jsonDecode`), pass it to the default
|
||||
`Style(...)` constructor instead of `Style.parse`.
|
||||
|
||||
## Sponsors
|
||||
|
||||
Advertisement: Many thanks to our sponsors who provide us with free or
|
||||
discounted products.
|
||||
|
||||
<a href="https://bunny.net/" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://www.dicebear.com/sponsors/bunny-light.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://www.dicebear.com/sponsors/bunny-dark.svg">
|
||||
<img alt="bunny.net" src="https://www.dicebear.com/sponsors/bunny-dark.svg" height="64">
|
||||
</picture>
|
||||
</a>
|
||||
@@ -0,0 +1,7 @@
|
||||
include: package:lints/recommended.yaml
|
||||
|
||||
analyzer:
|
||||
language:
|
||||
strict-casts: true
|
||||
strict-inference: true
|
||||
strict-raw-types: true
|
||||
@@ -0,0 +1,29 @@
|
||||
/// Unique avatars from dozens of styles — deterministic, customizable,
|
||||
/// vector-based.
|
||||
///
|
||||
/// The engine behind [DiceBear](https://www.dicebear.com). Pair it with the
|
||||
/// style definitions from the `dicebear_styles` package:
|
||||
///
|
||||
/// ```dart
|
||||
/// import 'package:dicebear_core/dicebear_core.dart';
|
||||
/// import 'package:dicebear_styles/adventurer.dart';
|
||||
///
|
||||
/// void main() {
|
||||
/// final style = Style.parse(adventurer);
|
||||
/// final avatar = Avatar(style, {'seed': 'Felix'});
|
||||
///
|
||||
/// print(avatar.svg);
|
||||
/// }
|
||||
/// ```
|
||||
library;
|
||||
|
||||
export 'src/avatar.dart' show Avatar;
|
||||
export 'src/error/circular_color_reference_error.dart'
|
||||
show CircularColorReferenceError;
|
||||
export 'src/error/options_validation_error.dart' show OptionsValidationError;
|
||||
export 'src/error/style_validation_error.dart' show StyleValidationError;
|
||||
export 'src/error/validation_error.dart'
|
||||
show ValidationError, ValidationErrorDetail;
|
||||
export 'src/options_descriptor.dart' show OptionsDescriptor;
|
||||
export 'src/style.dart' show Style;
|
||||
export 'src/utils/color.dart' show Color;
|
||||
@@ -0,0 +1,126 @@
|
||||
/// Top-level entry point for rendering an avatar from a style and options.
|
||||
library;
|
||||
|
||||
import 'options.dart';
|
||||
import 'renderer.dart';
|
||||
import 'resolver.dart';
|
||||
import 'style.dart';
|
||||
|
||||
/// Top-level entry point for rendering an avatar from a style and options.
|
||||
///
|
||||
/// Construction immediately resolves and renders the SVG; the various
|
||||
/// accessors return different serializations of that result. Throws an
|
||||
/// `OptionsValidationError` on invalid options and a
|
||||
/// `CircularColorReferenceError` when the style's colors reference each other
|
||||
/// in a cycle.
|
||||
class Avatar {
|
||||
final String _svg;
|
||||
final Map<String, Object?> _resolvedOptions;
|
||||
|
||||
/// Resolves and renders an avatar for [style]. A `null` options map is
|
||||
/// treated as empty.
|
||||
factory Avatar(Style style, [Map<String, Object?>? options]) {
|
||||
final resolver = Resolver(style, Options(options));
|
||||
final svg = Renderer(style, resolver).render();
|
||||
|
||||
return Avatar._(svg, resolver.resolved());
|
||||
}
|
||||
|
||||
Avatar._(this._svg, this._resolvedOptions);
|
||||
|
||||
/// Returns the rendered SVG markup.
|
||||
String get svg => _svg;
|
||||
|
||||
/// Returns the rendered SVG markup.
|
||||
@override
|
||||
String toString() => _svg;
|
||||
|
||||
/// Returns the avatar as a JSON-encodable map containing the SVG and the
|
||||
/// fully resolved options used to render it
|
||||
/// (`{'svg': ..., 'options': ...}`). `jsonEncode` picks this method up
|
||||
/// automatically.
|
||||
///
|
||||
/// The options map is a fresh deep copy in first-resolution order, with
|
||||
/// unset (`null`) values dropped and whole-number values normalized to
|
||||
/// `int` — see [resolvedOptions].
|
||||
Map<String, Object?> toJson() => {
|
||||
'svg': _svg,
|
||||
'options': _normalizedOptions(),
|
||||
};
|
||||
|
||||
/// Returns a deep copy of the fully resolved options used to render the
|
||||
/// avatar, in first-resolution order. The raw seed is deliberately
|
||||
/// excluded, and unset (`null`) entries are dropped — like
|
||||
/// `JSON.stringify` dropping `undefined` properties in the JS port.
|
||||
///
|
||||
/// Whole-number values are exposed as `int` so that `jsonEncode` emits
|
||||
/// `128`, not `128.0` — the JSON envelope must match the other ports
|
||||
/// byte-for-byte (the engine computes in doubles throughout; the
|
||||
/// conversion happens only here, at the exposure boundary).
|
||||
Map<String, Object?> get resolvedOptions => _normalizedOptions();
|
||||
|
||||
/// Returns the SVG encoded as a `data:image/svg+xml` URI.
|
||||
/// [Uri.encodeComponent] matches the JS `encodeURIComponent` contract for
|
||||
/// all well-formed input: every UTF-8 byte is percent-encoded except
|
||||
/// `A–Z a–z 0–9` and `-_.!~*'()`. An unpaired surrogate (only producible
|
||||
/// by ill-formed `seed`/`title` strings) is rejected here, mirroring the
|
||||
/// `URIError` the JS reference throws — Dart would otherwise silently
|
||||
/// substitute U+FFFD and diverge.
|
||||
String toDataUri() {
|
||||
for (var i = 0; i < _svg.length; i++) {
|
||||
final unit = _svg.codeUnitAt(i);
|
||||
|
||||
if (unit >= 0xD800 && unit <= 0xDBFF) {
|
||||
final next = i + 1 < _svg.length ? _svg.codeUnitAt(i + 1) : 0;
|
||||
|
||||
if (next >= 0xDC00 && next <= 0xDFFF) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (unit >= 0xD800 && unit <= 0xDFFF) {
|
||||
throw ArgumentError.value(
|
||||
_svg,
|
||||
'svg',
|
||||
'contains an unpaired surrogate at code unit $i and cannot be '
|
||||
'percent-encoded (the JS reference throws URIError here)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return 'data:image/svg+xml;charset=utf-8,${Uri.encodeComponent(_svg)}';
|
||||
}
|
||||
|
||||
Map<String, Object?> _normalizedOptions() => {
|
||||
for (final entry in _resolvedOptions.entries)
|
||||
if (entry.value != null) entry.key: _numValue(entry.value),
|
||||
};
|
||||
|
||||
/// Deep-copies a snapshot value, serializing numbers the way the JS port
|
||||
/// does: a whole number becomes a JSON integer (`256`), not a float
|
||||
/// (`256.0`). Fractional values and magnitudes at or beyond 2^53 (where
|
||||
/// `int` would lose the exact double value on the web) stay `double`.
|
||||
static Object? _numValue(Object? value) {
|
||||
if (value is double) {
|
||||
if (value == value.truncateToDouble() &&
|
||||
value.abs() < 9007199254740992.0) {
|
||||
return value.toInt();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value is List) {
|
||||
return [for (final item in value) _numValue(item)];
|
||||
}
|
||||
|
||||
if (value is Map) {
|
||||
return {
|
||||
for (final entry in value.entries) entry.key: _numValue(entry.value),
|
||||
};
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/// Thrown when a color in the style definition references itself, directly
|
||||
/// or indirectly. [chain] reproduces the resolution path.
|
||||
class CircularColorReferenceError implements Exception {
|
||||
/// The resolution path that closed the cycle, e.g. `[a, b, a]`.
|
||||
final List<String> chain;
|
||||
|
||||
/// The formatted error message.
|
||||
final String message;
|
||||
|
||||
CircularColorReferenceError(List<String> chain)
|
||||
: chain = List.unmodifiable(chain),
|
||||
message = 'Circular color reference: ${chain.join(' → ')}';
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'validation_error.dart';
|
||||
|
||||
/// Thrown when avatar options fail schema validation.
|
||||
class OptionsValidationError extends ValidationError {
|
||||
OptionsValidationError(List<ValidationErrorDetail> details)
|
||||
: super('Invalid options', details);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'validation_error.dart';
|
||||
|
||||
/// Thrown when a style definition fails schema validation.
|
||||
class StyleValidationError extends ValidationError {
|
||||
StyleValidationError(List<ValidationErrorDetail> details)
|
||||
: super('Invalid style definition', details);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/// A single field failure inside a [ValidationError].
|
||||
class ValidationErrorDetail {
|
||||
/// JSON pointer to the failing value (e.g. `/components/eyes/extends`),
|
||||
/// or `null` when the failure has no specific location.
|
||||
final String? instancePath;
|
||||
|
||||
/// Human-readable description of the failure.
|
||||
final String? message;
|
||||
|
||||
const ValidationErrorDetail({this.instancePath, this.message});
|
||||
}
|
||||
|
||||
/// Base class for schema validation errors. Carries the per-field failures
|
||||
/// in [details]; [message] is the formatted summary.
|
||||
class ValidationError implements Exception {
|
||||
/// The formatted error message, e.g.
|
||||
/// `Invalid options: /size must be >= 1`.
|
||||
final String message;
|
||||
|
||||
/// The per-field failures.
|
||||
final List<ValidationErrorDetail> details;
|
||||
|
||||
ValidationError(String prefix, List<ValidationErrorDetail> details)
|
||||
: message = _format(prefix, details),
|
||||
details = List.unmodifiable(details);
|
||||
|
||||
// Matches the JS port: empty-string paths and messages are skipped like
|
||||
// absent ones.
|
||||
static String _format(String prefix, List<ValidationErrorDetail> details) {
|
||||
final parts = <String>[];
|
||||
|
||||
for (final detail in details) {
|
||||
final segments = <String>[];
|
||||
final path = detail.instancePath;
|
||||
final message = detail.message;
|
||||
|
||||
if (path != null && path.isNotEmpty) {
|
||||
segments.add(path);
|
||||
}
|
||||
|
||||
if (message != null && message.isNotEmpty) {
|
||||
segments.add(message);
|
||||
}
|
||||
|
||||
parts.add(segments.join(' '));
|
||||
}
|
||||
|
||||
return '$prefix: ${parts.join(', ')}';
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/// Reads and normalizes the raw user-supplied options.
|
||||
library;
|
||||
|
||||
import 'error/options_validation_error.dart';
|
||||
import 'range.dart';
|
||||
import 'utils/deep_copy.dart';
|
||||
import 'validator/options_validator.dart';
|
||||
|
||||
/// Validates the raw user-supplied options and exposes them through typed
|
||||
/// accessors. Each accessor returns the user's input in a normalized form
|
||||
/// (always a list for options that accept either a scalar or a list, or
|
||||
/// `null` when the option is not set), so consumers — chiefly the resolver —
|
||||
/// never have to do their own normalization.
|
||||
///
|
||||
/// Resolution against the style definition and the PRNG happens in the
|
||||
/// resolver; this class is purely about reading user input. A JSON `null`
|
||||
/// value is treated like an absent key everywhere, matching the Go and Rust
|
||||
/// readers.
|
||||
class Options {
|
||||
final Map<String, Object?> _data;
|
||||
|
||||
/// Validates [data] (throws [OptionsValidationError]) and keeps a deep
|
||||
/// copy, so later mutations of the caller's map cannot leak into the
|
||||
/// avatar. `null` is treated as an empty options object.
|
||||
Options(Map<String, Object?>? data)
|
||||
: _data = _validateAndCopy(data ?? const <String, Object?>{});
|
||||
|
||||
String? seed() {
|
||||
final value = _get('seed');
|
||||
|
||||
return value is String ? value : null;
|
||||
}
|
||||
|
||||
double? size() {
|
||||
final value = _get('size');
|
||||
|
||||
return value is num ? value.toDouble() : null;
|
||||
}
|
||||
|
||||
bool? idRandomization() {
|
||||
final value = _get('idRandomization');
|
||||
|
||||
return value is bool ? value : null;
|
||||
}
|
||||
|
||||
String? title() {
|
||||
final value = _get('title');
|
||||
|
||||
return value is String ? value : null;
|
||||
}
|
||||
|
||||
List<String> flip() => _asStringArray(_get('flip'));
|
||||
|
||||
List<String> fontFamily() => _asStringArray(_get('fontFamily'));
|
||||
|
||||
List<double> fontWeight() => _asNumberArray(_get('fontWeight'));
|
||||
|
||||
Range? scale() => _toRange(_get('scale'));
|
||||
|
||||
Range? borderRadius() => _toRange(_get('borderRadius'));
|
||||
|
||||
Range? rotate() => _toRange(_get('rotate'));
|
||||
|
||||
Range? translateX() => _toRange(_get('translateX'));
|
||||
|
||||
Range? translateY() => _toRange(_get('translateY'));
|
||||
|
||||
/// Returns the user-set variant constraint for [name] as a weighted map, or
|
||||
/// `null` when `${name}Variant` is unset. A bare string or string list is
|
||||
/// normalized to a map with each entry weighted `1`, preserving the list's
|
||||
/// order (duplicates collapse last-wins, keeping the first position — the
|
||||
/// same semantics as JS `Object.fromEntries`).
|
||||
Map<String, double>? componentVariant(String name) {
|
||||
final raw = _get('${name}Variant');
|
||||
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (raw is String) {
|
||||
return {raw: 1.0};
|
||||
}
|
||||
|
||||
if (raw is List) {
|
||||
return {
|
||||
for (final item in raw)
|
||||
if (item is String) item: 1.0,
|
||||
};
|
||||
}
|
||||
|
||||
if (raw is Map) {
|
||||
// Non-numeric weights are silently dropped, like the Go and Rust
|
||||
// readers. Insertion order is kept — the PRNG sorts keys itself.
|
||||
return {
|
||||
for (final entry in raw.entries)
|
||||
if (entry.value is num)
|
||||
entry.key as String: (entry.value as num).toDouble(),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
double? componentProbability(String name) {
|
||||
final value = _get('${name}Probability');
|
||||
|
||||
return value is num ? value.toDouble() : null;
|
||||
}
|
||||
|
||||
/// Asymmetric on purpose: returns `null` (rather than `[]`) when
|
||||
/// `${name}Color` is unset so the resolver can fall back to the style
|
||||
/// definition's color values.
|
||||
List<String>? color(String name) {
|
||||
final raw = _get('${name}Color');
|
||||
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return _asStringArray(raw);
|
||||
}
|
||||
|
||||
List<String> colorFill(String name) =>
|
||||
_asStringArray(_get('${name}ColorFill'));
|
||||
|
||||
Range? colorAngle(String name) => _toRange(_get('${name}ColorAngle'));
|
||||
|
||||
Range? colorFillStops(String name) => _toRange(_get('${name}ColorFillStops'));
|
||||
|
||||
/// Returns the raw value of [key], with a JSON `null` value collapsed to
|
||||
/// absent (Dart map lookup already yields `null` for missing keys).
|
||||
Object? _get(String key) => _data[key];
|
||||
|
||||
/// Validates the user input, then deep-copies it so the stored data cannot
|
||||
/// alias the caller's map. Validation runs on the original input, like the
|
||||
/// JS port (validate, then `structuredClone`).
|
||||
static Map<String, Object?> _validateAndCopy(Map<String, Object?> data) {
|
||||
validateOptions(data);
|
||||
|
||||
return deepCopyJsonMap(data);
|
||||
}
|
||||
|
||||
/// Normalizes a scalar/list/absent string value into a fresh list.
|
||||
/// Wrong-typed list elements are silently dropped, like the Go and Rust
|
||||
/// readers.
|
||||
static List<String> _asStringArray(Object? value) {
|
||||
if (value is List) {
|
||||
return [
|
||||
for (final item in value)
|
||||
if (item is String) item,
|
||||
];
|
||||
}
|
||||
|
||||
if (value is String) {
|
||||
return [value];
|
||||
}
|
||||
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// Normalizes a scalar/list/absent numeric value into a fresh list.
|
||||
static List<double> _asNumberArray(Object? value) {
|
||||
if (value is List) {
|
||||
return [
|
||||
for (final item in value)
|
||||
if (item is num) item.toDouble(),
|
||||
];
|
||||
}
|
||||
|
||||
if (value is num) {
|
||||
return [value.toDouble()];
|
||||
}
|
||||
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// Normalizes a user-facing range option (bare number, `[n]`, `[min, max]`,
|
||||
/// or absent) into the internal [Range] struct. A bare number `n` — or a
|
||||
/// single-element list `[n]` — becomes a fixed `min == max`. A list's
|
||||
/// smallest/largest numeric element is taken as min/max (non-numeric
|
||||
/// elements are skipped). An empty list is treated as unset so the resolver
|
||||
/// applies the option's default (rather than yielding `NaN` from a missing
|
||||
/// bound). Matches the JS, PHP, Python, Go and Rust ports. User options
|
||||
/// never carry a step.
|
||||
static Range? _toRange(Object? value) {
|
||||
if (value is num) {
|
||||
final fixed = value.toDouble();
|
||||
|
||||
return Range(min: fixed, max: fixed);
|
||||
}
|
||||
|
||||
if (value is List) {
|
||||
final numbers = [
|
||||
for (final item in value)
|
||||
if (item is num) item.toDouble(),
|
||||
];
|
||||
|
||||
if (numbers.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var min = numbers.first;
|
||||
var max = numbers.first;
|
||||
|
||||
for (final n in numbers) {
|
||||
if (n < min) {
|
||||
min = n;
|
||||
}
|
||||
|
||||
if (n > max) {
|
||||
max = n;
|
||||
}
|
||||
}
|
||||
|
||||
return Range(min: min, max: max);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/// Builds a descriptor of every option a given style accepts.
|
||||
library;
|
||||
|
||||
import 'style.dart';
|
||||
import 'utils/deep_copy.dart';
|
||||
|
||||
/// Builds a descriptor of every option a given style accepts. Tooling such as
|
||||
/// the editor uses the result to render form controls and validation hints
|
||||
/// without having to introspect the style itself.
|
||||
class OptionsDescriptor {
|
||||
final Style _style;
|
||||
Map<String, Object?>? _descriptor;
|
||||
|
||||
OptionsDescriptor(Style style) : _style = style;
|
||||
|
||||
/// Returns a deep copy of the descriptor, building it lazily on first call.
|
||||
Map<String, Object?> toJson() => deepCopyJsonMap(_descriptor ??= _build());
|
||||
|
||||
/// Walks the style's components and colors and assembles the field map.
|
||||
/// The map's insertion order matches the JS port — the descriptor fixtures
|
||||
/// are compared serialized, so the order is part of the parity contract.
|
||||
Map<String, Object?> _build() {
|
||||
final result = <String, Object?>{
|
||||
'seed': {'type': 'string'},
|
||||
'size': {'type': 'number', 'min': 1, 'max': 4096},
|
||||
'idRandomization': {'type': 'boolean'},
|
||||
'title': {'type': 'string'},
|
||||
'flip': {
|
||||
'type': 'enum',
|
||||
'values': ['none', 'horizontal', 'vertical', 'both'],
|
||||
'list': true,
|
||||
},
|
||||
'fontFamily': {'type': 'string', 'list': true},
|
||||
'fontWeight': {'type': 'number', 'min': 1, 'max': 1000, 'list': true},
|
||||
'scale': {'type': 'range', 'min': 0, 'max': 10},
|
||||
'borderRadius': {'type': 'range', 'min': 0, 'max': 50},
|
||||
'rotate': _rotateRange,
|
||||
'translateX': _translateRange,
|
||||
'translateY': _translateRange,
|
||||
};
|
||||
|
||||
// Aliases are skipped: they accept no options of their own — the source
|
||||
// component's `${name}Variant` / `${name}Probability` covers them.
|
||||
for (final entry in _style.components.entries) {
|
||||
final component = entry.value;
|
||||
|
||||
if (component.extendsName != null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final variants = component.variants().keys.toList()..sort();
|
||||
|
||||
result['${entry.key}Variant'] = {
|
||||
'type': 'enum',
|
||||
'values': variants,
|
||||
'list': true,
|
||||
'weighted': true,
|
||||
};
|
||||
result['${entry.key}Probability'] = {
|
||||
'type': 'number',
|
||||
'min': 0,
|
||||
'max': 100,
|
||||
};
|
||||
}
|
||||
|
||||
// `background` is always appended: it is resolvable even when the style
|
||||
// defines no such color. A style that defines `background` itself writes
|
||||
// the same fields twice — the second write overwrites identically and
|
||||
// keeps the first position, like the JS object semantics.
|
||||
for (final name in [..._style.colors.keys, 'background']) {
|
||||
final contrastTo = _style.colors[name]?.contrastTo();
|
||||
|
||||
result['${name}Color'] = {
|
||||
'type': 'color',
|
||||
'list': true,
|
||||
// The JS truthy spread: an empty contrastTo is treated as unset.
|
||||
if (contrastTo != null && contrastTo.isNotEmpty)
|
||||
'contrastTo': contrastTo,
|
||||
};
|
||||
result['${name}ColorFill'] = {
|
||||
'type': 'enum',
|
||||
'values': ['solid', 'linear', 'radial'],
|
||||
'list': true,
|
||||
};
|
||||
result['${name}ColorFillStops'] = {'type': 'range', 'min': 2};
|
||||
result['${name}ColorAngle'] = _rotateRange;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Shared, immutable singletons reused across the rotate/translate/colorAngle
|
||||
// fields, mirroring the JS port's static descriptors. toJson deep-copies the
|
||||
// whole descriptor per call, so handing out the same instance is safe.
|
||||
static const Map<String, Object?> _rotateRange = {
|
||||
'type': 'range',
|
||||
'min': -360,
|
||||
'max': 360,
|
||||
};
|
||||
|
||||
static const Map<String, Object?> _translateRange = {
|
||||
'type': 'range',
|
||||
'min': -1000,
|
||||
'max': 1000,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/// FNV-1a 32-bit hash.
|
||||
///
|
||||
/// Offset basis: 0x811c9dc5, prime: 0x01000193.
|
||||
///
|
||||
/// See https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function
|
||||
library;
|
||||
|
||||
const int _mask32 = 0xFFFFFFFF;
|
||||
|
||||
/// Reproduces JavaScript's `Math.imul`: `(a * b) mod 2^32` for operands
|
||||
/// already in `[0, 2^32)`.
|
||||
///
|
||||
/// The 16-bit limb split keeps every intermediate below 2^33, which is exact
|
||||
/// in JS doubles — on the web a plain `a * b` of two 32-bit values loses
|
||||
/// precision above 2^53 and would silently corrupt the hash, while on the VM
|
||||
/// it would be correct but only by accident of 64-bit wrapping.
|
||||
int imul32(int a, int b) {
|
||||
final aLo = a & 0xFFFF;
|
||||
final aHi = (a >>> 16) & 0xFFFF;
|
||||
final bLo = b & 0xFFFF;
|
||||
final bHi = (b >>> 16) & 0xFFFF;
|
||||
|
||||
// (a*b) mod 2^32 = aLo*bLo + ((aHi*bLo + aLo*bHi) mod 2^16) << 16 (mod 2^32)
|
||||
return ((aLo * bLo) + (((aHi * bLo + aLo * bHi) & 0xFFFF) << 16)) & _mask32;
|
||||
}
|
||||
|
||||
/// Returns the unsigned 32-bit FNV-1a hash of [input].
|
||||
///
|
||||
/// The input is hashed by its UTF-16 code units (matching JS `charCodeAt`),
|
||||
/// so the result is identical across the language ports even for non-ASCII
|
||||
/// or non-BMP seeds. Dart strings are UTF-16 already, so [String.codeUnits]
|
||||
/// is iterated directly — a non-BMP character like 🎲 contributes two units
|
||||
/// (its surrogate pair). [imul32] wraps mod 2^32, reproducing JS
|
||||
/// `Math.imul` / `>>> 0`.
|
||||
int fnv1aHash(String input) {
|
||||
var hash = 0x811c9dc5;
|
||||
|
||||
for (final code in input.codeUnits) {
|
||||
// No mask needed on the XOR: code <= 0xFFFF and hash < 2^32.
|
||||
hash = imul32(hash ^ code, 0x01000193);
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
/// Returns the FNV-1a hash of [input] as an 8-character lowercase hex string.
|
||||
String fnv1aHex(String input) {
|
||||
return fnv1aHash(input).toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/// Mulberry32 PRNG.
|
||||
library;
|
||||
|
||||
import 'fnv1a.dart';
|
||||
|
||||
const int _mask32 = 0xFFFFFFFF;
|
||||
|
||||
/// 2^32, the divisor that maps an unsigned 32-bit value into `[0, 1)`.
|
||||
const int _uint32MaxPlus1 = 4294967296;
|
||||
|
||||
/// Mulberry32 PRNG — stateful, matching the C reference by Tommy Ettinger.
|
||||
///
|
||||
/// C original:
|
||||
///
|
||||
/// ```c
|
||||
/// uint32_t z = (x += 0x6D2B79F5UL);
|
||||
/// z = (z ^ (z >> 15)) * (z | 1UL);
|
||||
/// z ^= z + (z ^ (z >> 7)) * (z | 61UL);
|
||||
/// return z ^ (z >> 14);
|
||||
/// ```
|
||||
///
|
||||
/// All arithmetic is unsigned 32-bit: the state is kept in `[0, 2^32)` at all
|
||||
/// times, every addition is masked with `& 0xFFFFFFFF` before its result is
|
||||
/// used, and multiplication goes through [imul32]. This reproduces the JS
|
||||
/// reference's `Math.imul` / `>>>` / `| 0` behaviour exactly on both the VM
|
||||
/// and the web.
|
||||
///
|
||||
/// See https://gist.github.com/tommyettinger/46a874533244883189143505d203312c
|
||||
class Mulberry32 {
|
||||
int _state;
|
||||
|
||||
/// Creates a generator from an unsigned 32-bit [seed] (in practice always
|
||||
/// an FNV-1a hash, which may exceed 2^31 — e.g. 4294967295).
|
||||
Mulberry32(int seed) : _state = seed & _mask32;
|
||||
|
||||
/// Advances the state and returns the next unsigned 32-bit value.
|
||||
int next() {
|
||||
final z = _state = (_state + 0x6d2b79f5) & _mask32;
|
||||
|
||||
var t = imul32(z ^ (z >>> 15), z | 1);
|
||||
// The inner sum must be reduced mod 2^32 before the XOR: JS `^` applies
|
||||
// ToInt32 to the unbounded double sum.
|
||||
t ^= (t + imul32(t ^ (t >>> 7), t | 61)) & _mask32;
|
||||
|
||||
return t ^ (t >>> 14);
|
||||
}
|
||||
|
||||
/// Advances the state and returns the next value in `[0, 1)`.
|
||||
///
|
||||
/// Dividing an exact integer below 2^32 by 2^32 is exact in IEEE-754
|
||||
/// doubles, so the result is bit-identical across the language ports.
|
||||
double nextFloat() {
|
||||
return next() / _uint32MaxPlus1;
|
||||
}
|
||||
|
||||
/// Returns the current internal state as a signed 32-bit value, matching
|
||||
/// the JS reference where the state is stored via `| 0`. Exercised by the
|
||||
/// parity tests.
|
||||
int state() {
|
||||
return _state.toSigned(32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/// Key-based pseudorandom number generator and its primitives (FNV-1a,
|
||||
/// Mulberry32). Each method takes a key that, combined with the seed,
|
||||
/// produces a deterministic value: the same seed + key always yields the
|
||||
/// same result, regardless of call order.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import '../range.dart';
|
||||
import '../utils/number.dart';
|
||||
import 'fnv1a.dart';
|
||||
import 'mulberry32.dart';
|
||||
|
||||
/// Key-based pseudorandom number generator.
|
||||
///
|
||||
/// Each method takes a key that, combined with the seed, produces a
|
||||
/// deterministic value. The same seed + key always yields the same result,
|
||||
/// regardless of call order.
|
||||
class Prng {
|
||||
final String _seed;
|
||||
|
||||
Prng(String seed) : _seed = seed;
|
||||
|
||||
/// Returns a single float in `[0, 1)` derived from `seed:key`. The same
|
||||
/// seed/key pair always produces the same value.
|
||||
double getValue(String key) {
|
||||
return Mulberry32(fnv1aHash('$_seed:$key')).nextFloat();
|
||||
}
|
||||
|
||||
/// Picks a single item from [items] deterministically. Returns `null` for
|
||||
/// an empty list. Duplicate values (by string representation) are collapsed
|
||||
/// before picking so that input order and duplication do not affect the
|
||||
/// result.
|
||||
T? pick<T extends Object>(String key, List<T> items) {
|
||||
if (items.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (items.length == 1) {
|
||||
return items[0];
|
||||
}
|
||||
|
||||
final unique = _uniqueByCodePoint(items);
|
||||
|
||||
if (unique.length == 1) {
|
||||
return unique[0];
|
||||
}
|
||||
|
||||
// After dedupe all sort keys are distinct, so the instability of
|
||||
// Dart's `List.sort` cannot be observed.
|
||||
unique.sort(_compareByCodePoint);
|
||||
final index = (getValue(key) * unique.length).floor();
|
||||
|
||||
return unique[index];
|
||||
}
|
||||
|
||||
/// Picks a key from [weights] proportional to its weight. When all weights
|
||||
/// are zero, falls back to an unweighted [pick]. Returns `null` for an
|
||||
/// empty map.
|
||||
String? weightedPick(String key, Map<String, double> weights) {
|
||||
if (weights.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (weights.length == 1) {
|
||||
return weights.keys.first;
|
||||
}
|
||||
|
||||
// `String.compareTo` is the UTF-16 code-unit order the JS reference
|
||||
// sorts by; the map's insertion order must not leak in.
|
||||
final sorted = weights.keys.toList()..sort();
|
||||
|
||||
// Sum in sorted-key order to match the JS reduce-over-sorted parity:
|
||||
// float addition is non-associative, so insertion order would diverge.
|
||||
var totalWeight = 0.0;
|
||||
|
||||
for (final k in sorted) {
|
||||
totalWeight += weights[k]!;
|
||||
}
|
||||
|
||||
if (totalWeight == 0) {
|
||||
return pick(key, sorted);
|
||||
}
|
||||
|
||||
final threshold = getValue(key) * totalWeight;
|
||||
var cumulative = 0.0;
|
||||
|
||||
for (final k in sorted) {
|
||||
cumulative += weights[k]!;
|
||||
|
||||
if (threshold < cumulative) {
|
||||
return k;
|
||||
}
|
||||
}
|
||||
|
||||
// Safety fallback for float accumulation edges.
|
||||
return sorted.last;
|
||||
}
|
||||
|
||||
/// Returns `true` with the given probability (0–100, default 50).
|
||||
///
|
||||
/// Named `boolean` because `bool` is a reserved type name in Dart; this is
|
||||
/// the JS port's `bool`.
|
||||
bool boolean(String key, [double likelihood = 50]) {
|
||||
return getValue(key) * 100 < likelihood;
|
||||
}
|
||||
|
||||
/// Returns a deterministic float in [range], rounded to four decimal
|
||||
/// places. With `range.step > 0`, the result is drawn uniformly from
|
||||
/// `{ min + i*step | 0 ≤ i ≤ floor((max - min) / step) }`, so both
|
||||
/// endpoints of an evenly-divisible range are equally likely. Non-positive
|
||||
/// or absent step means continuous. `min`/`max` are sorted internally, so
|
||||
/// a reversed pair is tolerated.
|
||||
double float(String key, Range range) {
|
||||
final min = math.min(range.min, range.max);
|
||||
final max = math.max(range.min, range.max);
|
||||
final step = range.step ?? 0;
|
||||
double value;
|
||||
|
||||
if (step > 0) {
|
||||
final buckets = ((max - min) / step).floorToDouble() + 1;
|
||||
final i = (getValue(key) * buckets).floorToDouble();
|
||||
value = min + i * step;
|
||||
} else {
|
||||
value = min + getValue(key) * (max - min);
|
||||
}
|
||||
|
||||
// JS Math.round rounds half toward +∞; Dart's `round()` would diverge
|
||||
// for negative halves. Multiply, round, then divide — per-operation,
|
||||
// matching the other ports.
|
||||
return roundHalfUp(value * 10000) / 10000;
|
||||
}
|
||||
|
||||
/// Returns a deterministic integer in [range]. `min`/`max` are sorted
|
||||
/// internally, so a reversed pair is tolerated. `range.step` is accepted
|
||||
/// for symmetry with [float] but ignored — integers already step by 1.
|
||||
int integer(String key, Range range) {
|
||||
final min = math.min(range.min, range.max);
|
||||
final max = math.max(range.min, range.max);
|
||||
|
||||
// Compute in doubles like the JS reference and convert at the end; the
|
||||
// floored value plus min is always integral and well below 2^53.
|
||||
return ((getValue(key) * (max - min + 1)).floorToDouble() + min).truncate();
|
||||
}
|
||||
|
||||
/// Fisher-Yates shuffle with chained Mulberry32 state. Duplicate values
|
||||
/// (by string representation) are collapsed before shuffling, so a
|
||||
/// caller's slice off the front cannot accidentally produce a repeated
|
||||
/// value. Always returns a new list.
|
||||
List<T> shuffle<T extends Object>(String key, List<T> items) {
|
||||
if (items.length <= 1) {
|
||||
return [...items];
|
||||
}
|
||||
|
||||
final result = _uniqueByCodePoint(items)..sort(_compareByCodePoint);
|
||||
|
||||
// One chained generator with the same `seed:key` derivation as
|
||||
// [getValue], so the first draw equals getValue(key); the remaining
|
||||
// n-2 draws continue the same state.
|
||||
final prng = Mulberry32(fnv1aHash('$_seed:$key'));
|
||||
|
||||
for (var i = result.length - 1; i > 0; i--) {
|
||||
final j = (prng.nextFloat() * (i + 1)).floor();
|
||||
final temp = result[i];
|
||||
|
||||
result[i] = result[j];
|
||||
result[j] = temp;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// JS `String(item)` semantics for dedupe/sort keys: identity for strings,
|
||||
/// the JS-compatible number formatter for nums (`fontWeight` lists are
|
||||
/// numeric). Never raw `double.toString()`, which Dart renders as `400.0`
|
||||
/// where JS prints `400`.
|
||||
String _jsString(Object item) {
|
||||
return item is num ? jsNumString(item) : item.toString();
|
||||
}
|
||||
|
||||
/// Deduplicates by string representation, keeping the first occurrence.
|
||||
/// Mirrors the cross-language sort key used by [_compareByCodePoint] so that
|
||||
/// every port collapses the same set of inputs.
|
||||
List<T> _uniqueByCodePoint<T extends Object>(List<T> items) {
|
||||
final seen = <String>{};
|
||||
final result = <T>[];
|
||||
|
||||
for (final item in items) {
|
||||
if (seen.add(_jsString(item))) {
|
||||
result.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Cross-language deterministic sort: compare by UTF-16 code units of the
|
||||
/// string representation (JS string `<` compares code units). Dart strings
|
||||
/// are UTF-16, so [String.compareTo] is exactly this — it agrees with
|
||||
/// code-point order for all BMP text and differs only for
|
||||
/// supplementary-plane characters, e.g. "😀" (0xD83D 0xDE00) sorts before
|
||||
/// U+E000 because 0xD83D < 0xE000.
|
||||
int _compareByCodePoint(Object a, Object b) {
|
||||
return _jsString(a).compareTo(_jsString(b));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/// A numeric range with an optional quantization step, normalized from the
|
||||
/// user-facing scalar-or-array option shapes by `Options`.
|
||||
class Range {
|
||||
final double min;
|
||||
final double max;
|
||||
final double? step;
|
||||
|
||||
const Range({required this.min, required this.max, this.step});
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
/// Walks a style's element tree and turns it into the final SVG markup.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'prng/fnv1a.dart';
|
||||
import 'resolver.dart';
|
||||
import 'style.dart';
|
||||
import 'style/canvas.dart';
|
||||
import 'style/component.dart';
|
||||
import 'style/element.dart';
|
||||
import 'utils/initials.dart';
|
||||
import 'utils/license.dart';
|
||||
import 'utils/number.dart';
|
||||
import 'utils/xml.dart';
|
||||
|
||||
/// Walks a style's element tree and turns it into the final SVG markup.
|
||||
///
|
||||
/// The renderer is single-use: it accumulates `<defs>` entries and per-render
|
||||
/// caches across method calls, so a fresh instance is required per avatar. A
|
||||
/// circular color reference encountered during resolution surfaces as a
|
||||
/// `CircularColorReferenceError` thrown from [render].
|
||||
class Renderer {
|
||||
final Style _style;
|
||||
final Resolver _resolver;
|
||||
|
||||
// Insertion-ordered like the JS Map: updating an existing key keeps its
|
||||
// original position — Dart's default map does both natively (Go wrote an
|
||||
// orderedDefs helper, Rust uses IndexMap).
|
||||
final Map<String, String> _defs = {};
|
||||
String? _cachedSeedHash;
|
||||
String? _cachedInitials;
|
||||
|
||||
Renderer(Style style, Resolver resolver)
|
||||
: _style = style,
|
||||
_resolver = resolver;
|
||||
|
||||
/// Builds the complete SVG document for the avatar.
|
||||
String render() {
|
||||
final canvas = _style.canvas;
|
||||
final background = _renderBackground(canvas);
|
||||
var body = _renderElements(canvas.elements);
|
||||
|
||||
// Order matters: scale and flip around center, then rotate, translate,
|
||||
// and finally clip with border radius (outermost wrapper).
|
||||
body = _applyScale(body, canvas);
|
||||
body = _applyFlip(body, canvas);
|
||||
body = _applyRotate(body, canvas);
|
||||
body = _applyTranslate(body, canvas);
|
||||
body = _applyBorderRadius('$background$body', canvas);
|
||||
|
||||
final metadata = licenseXml(_style.meta);
|
||||
final defs = _defs.isNotEmpty ? '<defs>${_defs.values.join()}</defs>' : '';
|
||||
|
||||
// Resolve size before title and the root attributes so the resolver memo
|
||||
// records the keys in the same order as the JS/PHP/Python ports — the
|
||||
// JSON envelope emits the resolved options in first-recorded order.
|
||||
final size = _resolver.size();
|
||||
|
||||
final title = _resolver.title();
|
||||
final escapedTitle = title != null ? escapeXml(title) : null;
|
||||
|
||||
final attrs = <String>[
|
||||
'xmlns="http://www.w3.org/2000/svg"',
|
||||
'viewBox="0 0 ${formatNumber(canvas.width)} ${formatNumber(canvas.height)}"',
|
||||
];
|
||||
|
||||
final rootAttributes = _renderAttributes(_style.attributes());
|
||||
|
||||
if (rootAttributes.isNotEmpty) {
|
||||
// _renderAttributes returns a leading space; the root attributes sit
|
||||
// between the fixed entries, so strip it here like the JS trimStart().
|
||||
attrs.add(rootAttributes.trimLeft());
|
||||
}
|
||||
|
||||
if (escapedTitle != null) {
|
||||
attrs.add('role="img"');
|
||||
attrs.add('aria-label="$escapedTitle"');
|
||||
} else {
|
||||
attrs.add('aria-hidden="true"');
|
||||
}
|
||||
|
||||
if (size != null) {
|
||||
final sizeValue = formatNumber(size);
|
||||
|
||||
attrs.add('width="$sizeValue"');
|
||||
attrs.add('height="$sizeValue"');
|
||||
}
|
||||
|
||||
final titleElement =
|
||||
escapedTitle != null ? '<title>$escapedTitle</title>' : '';
|
||||
|
||||
var svg = '<svg ${attrs.join(' ')}>'
|
||||
'<!-- Generated by DiceBear (https://dicebear.com) -->'
|
||||
'$metadata$defs$titleElement$body</svg>';
|
||||
|
||||
if (_resolver.idRandomization()) {
|
||||
svg = _randomizeIds(svg);
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/// Wraps [body] in a flip transform when `flip` is set to anything other
|
||||
/// than `'none'`.
|
||||
String _applyFlip(String body, Canvas canvas) {
|
||||
final flip = _resolver.flip();
|
||||
|
||||
if (flip == 'none') {
|
||||
return body;
|
||||
}
|
||||
|
||||
final w = formatNumber(canvas.width);
|
||||
final h = formatNumber(canvas.height);
|
||||
final String transform;
|
||||
|
||||
switch (flip) {
|
||||
case 'horizontal':
|
||||
transform = 'translate($w, 0) scale(-1, 1)';
|
||||
case 'vertical':
|
||||
transform = 'translate(0, $h) scale(1, -1)';
|
||||
case 'both':
|
||||
transform = 'translate($w, $h) scale(-1, -1)';
|
||||
default:
|
||||
return body;
|
||||
}
|
||||
|
||||
return '<g transform="$transform">$body</g>';
|
||||
}
|
||||
|
||||
/// Wraps [body] in a uniform scale transform around the canvas center when
|
||||
/// the option differs from `1`.
|
||||
String _applyScale(String body, Canvas canvas) {
|
||||
final scale = _resolver.scale();
|
||||
|
||||
if (scale == 1) {
|
||||
return body;
|
||||
}
|
||||
|
||||
final cx = canvas.width / 2;
|
||||
final cy = canvas.height / 2;
|
||||
|
||||
return '<g transform="translate(${formatNumber(cx)}, ${formatNumber(cy)}) '
|
||||
'scale(${formatNumber(scale)}) '
|
||||
'translate(${formatNumber(-cx)}, ${formatNumber(-cy)})">$body</g>';
|
||||
}
|
||||
|
||||
/// Clips [body] to the canvas rectangle (rounded when `borderRadius` is
|
||||
/// non-zero) and registers the corresponding `clipPath` in `<defs>`. The
|
||||
/// clip is always applied so transformed content cannot bleed past the
|
||||
/// canvas bounds, regardless of the consumer's `overflow` setting.
|
||||
String _applyBorderRadius(String body, Canvas canvas) {
|
||||
final radius = _resolver.borderRadius();
|
||||
final id = 'clip-${_hashSeed()}';
|
||||
|
||||
final rx = formatNumber(radius / 100 * canvas.width);
|
||||
final ry = formatNumber(radius / 100 * canvas.height);
|
||||
|
||||
_defs[id] = '<clipPath id="$id">'
|
||||
'<rect width="${formatNumber(canvas.width)}" '
|
||||
'height="${formatNumber(canvas.height)}" rx="$rx" ry="$ry"/>'
|
||||
'</clipPath>';
|
||||
|
||||
return '<g clip-path="url(#$id)">$body</g>';
|
||||
}
|
||||
|
||||
/// Wraps [body] in a rotation around the canvas center when `rotate` is
|
||||
/// non-zero.
|
||||
String _applyRotate(String body, Canvas canvas) {
|
||||
final rotate = _resolver.rotate();
|
||||
|
||||
if (rotate == 0) {
|
||||
return body;
|
||||
}
|
||||
|
||||
final cx = canvas.width / 2;
|
||||
final cy = canvas.height / 2;
|
||||
|
||||
return '<g transform="rotate(${formatNumber(rotate)}, '
|
||||
'${formatNumber(cx)}, ${formatNumber(cy)})">$body</g>';
|
||||
}
|
||||
|
||||
/// Wraps [body] in a translate transform when either `translateX` or
|
||||
/// `translateY` is non-zero. Offsets are interpreted as percentages of the
|
||||
/// canvas dimensions.
|
||||
String _applyTranslate(String body, Canvas canvas) {
|
||||
final tx = _resolver.translateX();
|
||||
final ty = _resolver.translateY();
|
||||
|
||||
if (tx == 0 && ty == 0) {
|
||||
return body;
|
||||
}
|
||||
|
||||
final x = formatNumber(tx / 100 * canvas.width);
|
||||
final y = formatNumber(ty / 100 * canvas.height);
|
||||
|
||||
return '<g transform="translate($x, $y)">$body</g>';
|
||||
}
|
||||
|
||||
/// Returns a `<rect>` filling the canvas with the resolved background
|
||||
/// color, or an empty string when no background colors are configured.
|
||||
/// This is the first resolver call of the render, so
|
||||
/// `backgroundColorFill` and `backgroundColor` open the snapshot.
|
||||
String _renderBackground(Canvas canvas) {
|
||||
final colors = _resolver.color('background');
|
||||
|
||||
if (colors.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<rect width="${formatNumber(canvas.width)}" '
|
||||
'height="${formatNumber(canvas.height)}" '
|
||||
'fill="${escapeXml(_resolveColorReference('background'))}"/>';
|
||||
}
|
||||
|
||||
/// Suffixes every `id` declaration and reference with a random hex string
|
||||
/// so that multiple instances of the same avatar do not collide in a shared
|
||||
/// document. Process randomness ([math.Random], the JS `Math.random()`) is
|
||||
/// intentional — a PRNG-derived suffix would produce the same ID for the
|
||||
/// same seed.
|
||||
String _randomizeIds(String svg) {
|
||||
// floor(random * 0xffffff) yields 0..0xfffffe (exclusive upper bound),
|
||||
// matching the JS reference rather than a masked 0..0xffffff.
|
||||
final suffix = (_random.nextDouble() * 0xffffff)
|
||||
.floor()
|
||||
.toRadixString(16)
|
||||
.padLeft(6, '0');
|
||||
|
||||
// A Dart Set keeps insertion order, like the JS Set the reference fills.
|
||||
final ids = <String>{
|
||||
for (final match in _idDeclaration.allMatches(svg)) match[1]!,
|
||||
};
|
||||
|
||||
if (ids.isEmpty) {
|
||||
return svg;
|
||||
}
|
||||
|
||||
final escaped = [
|
||||
for (final id in ids)
|
||||
id.replaceAllMapped(_regExpSpecials, (m) => '\\${m[0]}'),
|
||||
];
|
||||
final pattern =
|
||||
RegExp('(id="|url\\(#|href="#)(${escaped.join('|')})("|\\))');
|
||||
|
||||
return svg.replaceAllMapped(
|
||||
pattern,
|
||||
(m) => '${m[1]}${m[2]}-$suffix${m[3]}',
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders a list of elements and concatenates their markup.
|
||||
String _renderElements(List<ElementNode> elements) =>
|
||||
[for (final element in elements) _renderElement(element)].join();
|
||||
|
||||
/// Dispatches a single element to the renderer for its type.
|
||||
String _renderElement(ElementNode element) {
|
||||
switch (element.type) {
|
||||
case 'element':
|
||||
return _renderSvgElement(element);
|
||||
case 'text':
|
||||
return _renderTextElement(element);
|
||||
case 'component':
|
||||
return _renderComponentElement(element);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders an SVG element. The special `defs` name diverts children into
|
||||
/// the shared `<defs>` block.
|
||||
///
|
||||
/// Element names and attribute names are not escaped here — they are
|
||||
/// validated against a strict allowlist schema (no `<script>`, no event
|
||||
/// handlers). Values are escaped via [escapeXml].
|
||||
String _renderSvgElement(ElementNode element) {
|
||||
final name = element.name;
|
||||
|
||||
if (name == null || name.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (name == 'defs') {
|
||||
for (final child in element.children) {
|
||||
final rendered = _renderElement(child);
|
||||
|
||||
if (rendered.isNotEmpty) {
|
||||
// The def key is the child's literal `id` attribute (not a
|
||||
// color/variable reference object), else a positional `_N` key
|
||||
// evaluated at insertion time.
|
||||
final id = child.attributes?['id'];
|
||||
final key = id is String ? id : '_${_defs.length}';
|
||||
|
||||
_defs[key] = rendered;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
final attrs = _renderAttributes(element.attributes);
|
||||
final children = _renderElements(element.children);
|
||||
|
||||
if (children.isEmpty) {
|
||||
return '<$name$attrs/>';
|
||||
}
|
||||
|
||||
return '<$name$attrs>$children</$name>';
|
||||
}
|
||||
|
||||
/// Renders a text element by escaping its resolved value.
|
||||
String _renderTextElement(ElementNode element) {
|
||||
final value = element.value;
|
||||
|
||||
return value != null ? escapeXml(_resolveValue(value)) : '';
|
||||
}
|
||||
|
||||
/// Resolves a component reference to a chosen variant and emits a `<use>`
|
||||
/// pointing at a `<defs>` entry that holds the variant body. Aliases of the
|
||||
/// same source component sharing a variant — and identical components
|
||||
/// referenced more than once — therefore produce a single `<defs>` entry
|
||||
/// referenced by every `<use>`, never duplicated SVG markup.
|
||||
///
|
||||
/// Any `attributes` on the component reference are written to the emitted
|
||||
/// `<use>` tag. A user-supplied `transform` is prepended to the
|
||||
/// per-component transforms so it acts as the outer (placement) transform,
|
||||
/// with the style's translate/rotate/scale applied inside it.
|
||||
String _renderComponentElement(ElementNode element) {
|
||||
final componentName = element.name;
|
||||
|
||||
if (componentName == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final variantName = _resolver.variant(componentName);
|
||||
|
||||
if (variantName == null || variantName.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final component = _style.components[componentName];
|
||||
|
||||
if (component == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final variant = component.variants()[variantName];
|
||||
|
||||
if (variant == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// The def ID uses the source name, so aliases sharing a variant produce
|
||||
// one <g> def and several <use> references.
|
||||
final id = '${component.sourceName}-$variantName-${_hashSeed()}';
|
||||
|
||||
if (!_defs.containsKey(id)) {
|
||||
final body = _renderElements(variant.elements());
|
||||
|
||||
_defs[id] = '<g id="$id">$body</g>';
|
||||
}
|
||||
|
||||
final transforms = _buildTransforms(component);
|
||||
final userAttributes = element.attributes;
|
||||
Map<String, Object?>? mergedAttributes = userAttributes;
|
||||
|
||||
if (transforms.isNotEmpty) {
|
||||
final userTransform = userAttributes?['transform'];
|
||||
final allParts = userTransform is String && userTransform.isNotEmpty
|
||||
? [userTransform, ...transforms]
|
||||
: transforms;
|
||||
|
||||
// Assigning to an existing `transform` key keeps its position in the
|
||||
// attribute order; a new key is appended — the JS object spread
|
||||
// semantics (Dart maps behave the same way).
|
||||
mergedAttributes = {...?userAttributes};
|
||||
mergedAttributes['transform'] = allParts.join(' ');
|
||||
}
|
||||
|
||||
final attrs = _renderAttributes(mergedAttributes);
|
||||
|
||||
return '<use$attrs href="#$id"/>';
|
||||
}
|
||||
|
||||
/// Returns the per-component SVG `transform` fragments derived from the
|
||||
/// component's translate, rotate, and scale options. Translate values are
|
||||
/// percentages of the component canvas dimensions, matching the semantics
|
||||
/// of the user-facing `translateX` / `translateY` options.
|
||||
///
|
||||
/// The fragments are ordered so that, when joined into a single `transform`
|
||||
/// attribute, the scale is the rightmost (innermost) transform — applied
|
||||
/// first to a point, followed by rotate, then translate.
|
||||
List<String> _buildTransforms(Component component) {
|
||||
final transform = _resolver.componentTransform(component.name);
|
||||
final rotate = transform.rotate;
|
||||
final translateX = transform.translateX;
|
||||
final translateY = transform.translateY;
|
||||
final scale = transform.scale;
|
||||
|
||||
if (translateX == 0 && translateY == 0 && rotate == 0 && scale == 1) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final transforms = <String>[];
|
||||
final cx = component.width / 2;
|
||||
final cy = component.height / 2;
|
||||
final cxValue = formatNumber(cx);
|
||||
final cyValue = formatNumber(cy);
|
||||
|
||||
if (translateX != 0 || translateY != 0) {
|
||||
final x = formatNumber(translateX / 100 * component.width);
|
||||
final y = formatNumber(translateY / 100 * component.height);
|
||||
|
||||
transforms.add('translate($x, $y)');
|
||||
}
|
||||
|
||||
if (rotate != 0) {
|
||||
transforms.add('rotate(${formatNumber(rotate)}, $cxValue, $cyValue)');
|
||||
}
|
||||
|
||||
if (scale != 1) {
|
||||
transforms.add('translate($cxValue, $cyValue) '
|
||||
'scale(${formatNumber(scale)}) '
|
||||
'translate(${formatNumber(-cx)}, ${formatNumber(-cy)})');
|
||||
}
|
||||
|
||||
return transforms;
|
||||
}
|
||||
|
||||
/// Serializes an attribute map to a leading space-prefixed string suitable
|
||||
/// for inlining into a tag. Returns an empty string when there are no
|
||||
/// attributes to render. Entries render in map insertion order — SVG
|
||||
/// attribute order is load-bearing for byte parity.
|
||||
String _renderAttributes(Map<String, Object?>? attributes) {
|
||||
if (attributes == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final parts = <String>[];
|
||||
|
||||
for (final entry in attributes.entries) {
|
||||
final value = entry.value;
|
||||
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.add('${entry.key}="${escapeXml(_resolveAttributeValue(value))}"');
|
||||
}
|
||||
|
||||
if (parts.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return ' ${parts.join(' ')}';
|
||||
}
|
||||
|
||||
/// Resolves a single attribute value: literal strings pass through, color
|
||||
/// and variable references are dereferenced through the option resolver.
|
||||
String _resolveAttributeValue(Object value) {
|
||||
if (value is String) {
|
||||
return value;
|
||||
}
|
||||
|
||||
final reference = value as Map<String, Object?>;
|
||||
final name = reference['name'] as String;
|
||||
|
||||
if (reference['type'] == 'color') {
|
||||
return _resolveColorReference(name);
|
||||
}
|
||||
|
||||
return _resolveVariable(name);
|
||||
}
|
||||
|
||||
/// Resolves a named color into either a hex string (solid fill / single
|
||||
/// color) or a `url(#…)` gradient reference, registering the gradient in
|
||||
/// `<defs>` as a side effect.
|
||||
String _resolveColorReference(String name) {
|
||||
final colors = _resolver.color(name);
|
||||
final fill = _resolver.colorFill(name);
|
||||
|
||||
if (fill == 'solid' || colors.length <= 1) {
|
||||
return colors.isNotEmpty ? colors[0] : 'none';
|
||||
}
|
||||
|
||||
return _buildGradientDef(name, colors, fill);
|
||||
}
|
||||
|
||||
/// Builds the `<linearGradient>` or `<radialGradient>` for the given color
|
||||
/// definition, registers it in `<defs>`, and returns its `url(#…)`
|
||||
/// reference. This is the only caller of `colorAngle`, so the angle is
|
||||
/// drawn — and recorded in the snapshot — only when a gradient is built.
|
||||
String _buildGradientDef(String name, List<String> colors, String fill) {
|
||||
final rotation = _resolver.colorAngle(name);
|
||||
final id = '$name-color-${_hashSeed()}';
|
||||
final tag = fill == 'linear' ? 'linearGradient' : 'radialGradient';
|
||||
final rotateAttr = rotation != 0
|
||||
? ' gradientTransform="rotate(${formatNumber(rotation)}, 0.5, 0.5)"'
|
||||
: '';
|
||||
final stops = StringBuffer();
|
||||
|
||||
for (var i = 0; i < colors.length; i++) {
|
||||
final offset = formatNumber(i / (colors.length - 1) * 100);
|
||||
|
||||
stops.write(
|
||||
'<stop offset="$offset%" stop-color="${escapeXml(colors[i])}"/>',
|
||||
);
|
||||
}
|
||||
|
||||
_defs[id] = '<$tag id="$id"$rotateAttr>$stops</$tag>';
|
||||
|
||||
return 'url(#$id)';
|
||||
}
|
||||
|
||||
/// Resolves an element value to its final string form. Literal strings pass
|
||||
/// through; variable references are dereferenced.
|
||||
String _resolveValue(Object value) {
|
||||
if (value is String) {
|
||||
return value;
|
||||
}
|
||||
|
||||
final reference = value as Map<String, Object?>;
|
||||
|
||||
if (reference['type'] == 'variable') {
|
||||
return _resolveVariable(reference['name'] as String);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/// Resolves a built-in variable reference to its current value.
|
||||
String _resolveVariable(String name) {
|
||||
switch (name) {
|
||||
case 'initial':
|
||||
// charAt(0) would return a lone surrogate (ill-formed XML) for
|
||||
// supplementary-plane initials; take the full first code point
|
||||
// instead, like the JS/PHP/Python/Rust/Go ports.
|
||||
final initials = _initials();
|
||||
|
||||
return initials.isEmpty
|
||||
? ''
|
||||
: String.fromCharCode(initials.runes.first);
|
||||
case 'initials':
|
||||
return _initials();
|
||||
case 'fontWeight':
|
||||
return formatNumber(_resolver.fontWeight());
|
||||
case 'fontFamily':
|
||||
return _resolver.fontFamily();
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the seed-derived initials, cached after the first call.
|
||||
String _initials() => _cachedInitials ??= initialsFromSeed(_resolver.seed());
|
||||
|
||||
/// Returns the FNV-1a hex hash of the seed, cached after the first call.
|
||||
/// The value is used to derive stable but unique IDs for `<defs>` entries.
|
||||
String _hashSeed() => _cachedSeedHash ??= fnv1aHex(_resolver.seed());
|
||||
}
|
||||
|
||||
// Shared process-randomness source for _randomizeIds.
|
||||
final math.Random _random = math.Random();
|
||||
|
||||
final RegExp _idDeclaration = RegExp(r'\bid="([^"]+)"');
|
||||
|
||||
// The JS reference's regex-escape set: [.*+?^${}()|[\]\\].
|
||||
final RegExp _regExpSpecials = RegExp(r'[.*+?^${}()|[\]\\]');
|
||||
@@ -0,0 +1,280 @@
|
||||
/// Derives every deterministic value for an avatar from the style, the user
|
||||
/// options, and a seeded PRNG, exposing them as memoized named accessors.
|
||||
library;
|
||||
|
||||
import 'error/circular_color_reference_error.dart';
|
||||
import 'options.dart';
|
||||
import 'prng/prng.dart';
|
||||
import 'range.dart';
|
||||
import 'style.dart';
|
||||
import 'style/color.dart';
|
||||
import 'style/component.dart';
|
||||
import 'utils/color.dart';
|
||||
|
||||
/// Bundles the three inputs needed to derive any deterministic value for an
|
||||
/// avatar — the [Style], the validated user [Options], and a seeded [Prng] —
|
||||
/// and exposes them as named accessors. Each accessor memoizes its result so
|
||||
/// that repeated calls cannot drift. The memo also serves as the
|
||||
/// informational snapshot returned by [resolved] — every value the resolver
|
||||
/// picks during one resolution lands there, except for the raw seed.
|
||||
class Resolver {
|
||||
final Style _style;
|
||||
final Options _options;
|
||||
final Prng _prng;
|
||||
final List<String> _colorResolving = [];
|
||||
|
||||
// The memo doubles as the resolved-options snapshot: the first write wins,
|
||||
// and the map's insertion order is the first-resolution order the JSON
|
||||
// envelope emits (Dart maps preserve it natively; Go had to track the order
|
||||
// explicitly). Unset values are recorded as null and filtered on exposure.
|
||||
final Map<String, Object?> _result = {};
|
||||
|
||||
Resolver(Style style, Options options)
|
||||
: _style = style,
|
||||
_options = options,
|
||||
_prng = Prng(options.seed() ?? '');
|
||||
|
||||
/// Deliberately not memoized — the seed is the only input we keep out of
|
||||
/// the [resolved] snapshot, so a serialized avatar never leaks it.
|
||||
String seed() => _options.seed() ?? '';
|
||||
|
||||
double? size() => _memo('size', () => _options.size());
|
||||
|
||||
bool idRandomization() =>
|
||||
_memo('idRandomization', () => _options.idRandomization() ?? false);
|
||||
|
||||
String? title() => _memo('title', () => _options.title());
|
||||
|
||||
String flip() =>
|
||||
_memo('flip', () => _prng.pick('flip', _options.flip()) ?? 'none');
|
||||
|
||||
String fontFamily() => _memo(
|
||||
'fontFamily',
|
||||
() => _prng.pick('fontFamily', _options.fontFamily()) ?? 'system-ui',
|
||||
);
|
||||
|
||||
double fontWeight() => _memo(
|
||||
'fontWeight',
|
||||
() => _prng.pick('fontWeight', _options.fontWeight()) ?? 400.0,
|
||||
);
|
||||
|
||||
double scale() => _memoFloat('scale', _options.scale(), 1);
|
||||
|
||||
double borderRadius() =>
|
||||
_memoFloat('borderRadius', _options.borderRadius(), 0);
|
||||
|
||||
double rotate() => _memoFloat('rotate', _options.rotate(), 0);
|
||||
|
||||
double translateX() => _memoFloat('translateX', _options.translateX(), 0);
|
||||
|
||||
double translateY() => _memoFloat('translateY', _options.translateY(), 0);
|
||||
|
||||
/// Selects a variant for the given component, or `null` when the component
|
||||
/// is unknown or rolled invisible. Depending on what was passed as
|
||||
/// `${name}Variant` in the input data:
|
||||
///
|
||||
/// - unset: the PRNG picks from all style variants using their weights.
|
||||
/// - otherwise: the PRNG picks using the user-supplied weighted map (a bare
|
||||
/// string or string list is normalized to weight `1` each in
|
||||
/// [Options.componentVariant]).
|
||||
///
|
||||
/// Only variants that exist in the style definition are considered. User
|
||||
/// option lookup uses the component's source name (so a single option
|
||||
/// propagates to every alias), while the PRNG keys use the element's own
|
||||
/// name — each alias rolls its own visibility and variant.
|
||||
String? variant(String name) {
|
||||
return _memo('${name}Variant', () {
|
||||
final component = _style.components[name];
|
||||
|
||||
if (component == null || !_isVisible(name, component)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final raw = _options.componentVariant(component.sourceName);
|
||||
final variants = component.variants();
|
||||
final weights = <String, double>{};
|
||||
|
||||
if (raw == null) {
|
||||
for (final entry in variants.entries) {
|
||||
weights[entry.key] = entry.value.weight();
|
||||
}
|
||||
} else {
|
||||
for (final entry in raw.entries) {
|
||||
if (variants.containsKey(entry.key)) {
|
||||
weights[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _prng.weightedPick('${name}Variant', weights);
|
||||
});
|
||||
}
|
||||
|
||||
/// Resolves a named color to its final stop list.
|
||||
///
|
||||
/// The memo is also a DoS guard: a color already resolved this pass is
|
||||
/// returned from the snapshot instead of being recomputed. Without it, a
|
||||
/// graph where each color references the next via both `contrastTo` and
|
||||
/// `notEqualTo` re-resolves exponentially (a schema-valid hang).
|
||||
List<String> color(String name) =>
|
||||
_memo('${name}Color', () => _resolveColor(name));
|
||||
|
||||
String colorFill(String name) => _memo(
|
||||
'${name}ColorFill',
|
||||
() =>
|
||||
_prng.pick('${name}ColorFill', _options.colorFill(name)) ?? 'solid',
|
||||
);
|
||||
|
||||
/// Memoized like every float option, but only ever called while a gradient
|
||||
/// def is built — `${name}ColorAngle` therefore appears in the snapshot
|
||||
/// only for colors that actually rendered as a gradient.
|
||||
double colorAngle(String name) =>
|
||||
_memoFloat('${name}ColorAngle', _options.colorAngle(name), 0);
|
||||
|
||||
/// Picks the rotate/translateX/translateY/scale values for a single
|
||||
/// component. Memoized per `name`, so the four values land in [resolved] as
|
||||
/// `${name}Rotate` / `${name}TranslateX` / `${name}TranslateY` /
|
||||
/// `${name}Scale` for downstream introspection — recorded in exactly that
|
||||
/// order, which the snapshot key order depends on.
|
||||
({double rotate, double translateX, double translateY, double scale})
|
||||
componentTransform(String name) {
|
||||
final component = _style.components[name];
|
||||
|
||||
// Record fields evaluate in source order: Rotate, TranslateX,
|
||||
// TranslateY, Scale — the same order the JS port memoizes them in.
|
||||
return (
|
||||
rotate: _memoFloat('${name}Rotate', component?.rotate(), 0),
|
||||
translateX:
|
||||
_memoFloat('${name}TranslateX', component?.translate().x(), 0),
|
||||
translateY:
|
||||
_memoFloat('${name}TranslateY', component?.translate().y(), 0),
|
||||
scale: _memoFloat('${name}Scale', component?.scale(), 1),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns an informational snapshot of every value the resolver picked.
|
||||
/// Includes top-level options (scale/rotate/translate/…), per-component
|
||||
/// variants/colors, and per-component transform picks. The raw seed is
|
||||
/// deliberately excluded; unset values are recorded as `null` and filtered
|
||||
/// out on exposure (Avatar drops them, like `JSON.stringify` drops
|
||||
/// `undefined` properties in JS).
|
||||
///
|
||||
/// The returned map aliases the internal cache; callers that need isolation
|
||||
/// (e.g. `Avatar.toJson`) clone it themselves.
|
||||
Map<String, Object?> resolved() => _result;
|
||||
|
||||
/// Returns the visibility probability (0–100) for the given component.
|
||||
/// Aliases read the source component's user-set probability so a single
|
||||
/// `<source>Probability` option propagates to every alias of the source.
|
||||
double _probability(Component component) =>
|
||||
_options.componentProbability(component.sourceName) ??
|
||||
component.probability();
|
||||
|
||||
// `${name}Probability` is a PRNG key but never a memo key — visibility
|
||||
// rolls are not part of the resolved snapshot in any port.
|
||||
bool _isVisible(String name, Component component) =>
|
||||
_prng.boolean('${name}Probability', _probability(component));
|
||||
|
||||
/// Resolves a named color to its final stop list, applying contrast sorting
|
||||
/// and `notEqualTo` filtering from the style definition. Detects circular
|
||||
/// references between colors and throws [CircularColorReferenceError].
|
||||
List<String> _resolveColor(String name) {
|
||||
final userColors = _options.color(name);
|
||||
final styleColor = _style.colors[name];
|
||||
final source = userColors ?? styleColor?.values() ?? const <String>[];
|
||||
|
||||
var candidates = [for (final c in source) Color.toHex(c)];
|
||||
|
||||
// colorFill is memoized inside this computation, so it lands in the
|
||||
// snapshot before `${name}Color` — the memo writes after compute returns.
|
||||
final fill = colorFill(name);
|
||||
final stops = fill == 'solid' ? 1 : _colorFillStops(name);
|
||||
|
||||
if (styleColor == null) {
|
||||
return _takeN(_prng.shuffle('${name}Color', candidates), stops);
|
||||
}
|
||||
|
||||
// Detect circular references (e.g. a.contrastTo = b, b.contrastTo = a).
|
||||
if (_colorResolving.contains(name)) {
|
||||
throw CircularColorReferenceError([..._colorResolving, name]);
|
||||
}
|
||||
|
||||
_colorResolving.add(name);
|
||||
final contrastTo = _contrastTo(styleColor);
|
||||
final notEqualTo = styleColor.notEqualTo();
|
||||
|
||||
try {
|
||||
if (contrastTo != null) {
|
||||
final refColors = color(contrastTo);
|
||||
|
||||
if (refColors.isNotEmpty) {
|
||||
candidates = Color.sortByContrast(candidates, refColors[0]);
|
||||
}
|
||||
}
|
||||
|
||||
if (notEqualTo.isNotEmpty) {
|
||||
final excluded = <String>[];
|
||||
|
||||
for (final ref in notEqualTo) {
|
||||
excluded.addAll(color(ref));
|
||||
}
|
||||
|
||||
candidates = Color.filterNotEqualTo(candidates, excluded);
|
||||
}
|
||||
} finally {
|
||||
_colorResolving.removeLast();
|
||||
}
|
||||
|
||||
// Skip the shuffle when sorted by contrast, to preserve that ordering.
|
||||
final ordered = contrastTo != null
|
||||
? candidates
|
||||
: _prng.shuffle('${name}Color', candidates);
|
||||
|
||||
return _takeN(ordered, stops);
|
||||
}
|
||||
|
||||
// The JS port's truthy check: an empty `contrastTo` counts as unset.
|
||||
static String? _contrastTo(ColorDefinition styleColor) {
|
||||
final value = styleColor.contrastTo();
|
||||
|
||||
return value == null || value.isEmpty ? null : value;
|
||||
}
|
||||
|
||||
/// Draws the gradient stop count, defaulting to `2`. Not memoized — the
|
||||
/// `${name}ColorFillStops` PRNG key never appears in the snapshot.
|
||||
int _colorFillStops(String name) {
|
||||
final range = _options.colorFillStops(name);
|
||||
|
||||
return range != null ? _prng.integer('${name}ColorFillStops', range) : 2;
|
||||
}
|
||||
|
||||
double _memoFloat(String key, Range? range, double fallback) =>
|
||||
_memo(key, () => range != null ? _prng.float(key, range) : fallback);
|
||||
|
||||
T _memo<T>(String key, T Function() compute) {
|
||||
if (_result.containsKey(key)) {
|
||||
return _result[key] as T;
|
||||
}
|
||||
|
||||
final value = compute();
|
||||
|
||||
_result[key] = value;
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Returns a copy of the first [n] elements of [list] (or all of them when
|
||||
/// [n] is larger), mirroring the JS `slice(0, stops)`. Negative counts are
|
||||
/// clamped to 0, like the Go and Rust ports — the schema forbids them.
|
||||
static List<String> _takeN(List<String> list, int n) {
|
||||
if (n > list.length) {
|
||||
n = list.length;
|
||||
}
|
||||
|
||||
if (n < 0) {
|
||||
n = 0;
|
||||
}
|
||||
|
||||
return list.sublist(0, n);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/// The validated, lazily-decomposed model of a style definition: the element
|
||||
/// tree, components (with aliases resolved), colors, and metadata.
|
||||
library;
|
||||
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'error/style_validation_error.dart';
|
||||
import 'error/validation_error.dart';
|
||||
import 'style/canvas.dart';
|
||||
import 'style/color.dart';
|
||||
import 'style/component.dart';
|
||||
import 'style/meta.dart';
|
||||
import 'utils/deep_copy.dart';
|
||||
import 'validator/style_validator.dart';
|
||||
|
||||
/// Validated, lazily-decomposed wrapper around a style definition.
|
||||
///
|
||||
/// Construction runs the JSON Schema validator and the alias cross-reference
|
||||
/// check, and stores a deep copy of the input so that later mutation of the
|
||||
/// source map cannot leak into the rendered avatar (the JS port's
|
||||
/// `structuredClone`). Build it once, then reuse it across many avatars.
|
||||
class Style {
|
||||
final Map<String, Object?> _data;
|
||||
Meta? _meta;
|
||||
Canvas? _canvas;
|
||||
Map<String, Component>? _components;
|
||||
Map<String, ColorDefinition>? _colors;
|
||||
|
||||
/// Creates a style from an already-decoded style definition.
|
||||
///
|
||||
/// Throws a [StyleValidationError] when [definition] violates the style
|
||||
/// definition schema or contains an invalid component alias. See
|
||||
/// [Style.parse] to build one directly from a raw JSON string.
|
||||
Style(Map<String, Object?> definition) : _data = _validatedCopy(definition) {
|
||||
_validateAliases();
|
||||
}
|
||||
|
||||
/// Parses and validates a style definition from its raw JSON [definition].
|
||||
///
|
||||
/// This is the string counterpart to the default constructor, for the
|
||||
/// common case where the definition is raw JSON, such as the style
|
||||
/// constants shipped by the `dicebear_styles` package:
|
||||
///
|
||||
/// ```dart
|
||||
/// import 'package:dicebear_styles/lorelei.dart';
|
||||
///
|
||||
/// final style = Style.parse(lorelei);
|
||||
/// ```
|
||||
///
|
||||
/// Throws a [FormatException] when [definition] is not valid JSON, and a
|
||||
/// [StyleValidationError] when the parsed value is not a valid style
|
||||
/// definition (a JSON value that is not an object fails the same way). Use
|
||||
/// the default constructor when you already hold a decoded map.
|
||||
factory Style.parse(String definition) {
|
||||
final decoded = jsonDecode(definition);
|
||||
|
||||
// A valid definition is a JSON object. Route anything else through the
|
||||
// validator so it surfaces as a StyleValidationError like every other
|
||||
// malformed definition, not as a raw cast error.
|
||||
if (decoded is! Map) {
|
||||
validateStyle(decoded);
|
||||
}
|
||||
|
||||
return Style((decoded as Map).cast<String, Object?>());
|
||||
}
|
||||
|
||||
// Validation runs on the caller's map first, then the copy is taken —
|
||||
// matching the JS port's `validate(data)` before `structuredClone(data)`.
|
||||
static Map<String, Object?> _validatedCopy(Map<String, Object?> definition) {
|
||||
validateStyle(definition);
|
||||
|
||||
return deepCopyJsonMap(definition);
|
||||
}
|
||||
|
||||
/// Returns the definition's `$id`, or `null` when not set.
|
||||
String? get id => _data[r'$id'] as String?;
|
||||
|
||||
/// Returns the definition's `$schema` URI, or `null` when not set.
|
||||
String? get schema => _data[r'$schema'] as String?;
|
||||
|
||||
/// Returns the definition's `$comment`, or `null` when not set.
|
||||
String? get comment => _data[r'$comment'] as String?;
|
||||
|
||||
/// Returns the [Meta] view, lazily constructed on first access and
|
||||
/// defaulting to an empty block when the definition omits `meta`.
|
||||
Meta get meta =>
|
||||
_meta ??= Meta(_data['meta'] as Map<String, Object?>? ?? const {});
|
||||
|
||||
/// Returns the [Canvas] view, lazily constructed on first access.
|
||||
Canvas get canvas =>
|
||||
_canvas ??= Canvas(_data['canvas'] as Map<String, Object?>);
|
||||
|
||||
/// Returns a name → [Component] map for all defined components, built
|
||||
/// lazily on first access.
|
||||
///
|
||||
/// The map is built in two passes: all non-alias entries first (definition
|
||||
/// order), then all alias entries (definition order), each alias sharing
|
||||
/// the source component's data. The resulting insertion order — non-aliases
|
||||
/// before aliases — is observable through `OptionsDescriptor` and must not
|
||||
/// change.
|
||||
Map<String, Component> get components {
|
||||
final cached = _components;
|
||||
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final entries = _data['components'] as Map<String, Object?>? ?? const {};
|
||||
final map = <String, Component>{};
|
||||
|
||||
for (final entry in entries.entries) {
|
||||
final data = entry.value as Map<String, Object?>;
|
||||
|
||||
if (!_isAlias(data)) {
|
||||
map[entry.key] = Component(entry.key, data);
|
||||
}
|
||||
}
|
||||
|
||||
for (final entry in entries.entries) {
|
||||
final data = entry.value as Map<String, Object?>;
|
||||
|
||||
if (_isAlias(data)) {
|
||||
final target = data['extends'] as String;
|
||||
final source = map[target];
|
||||
|
||||
// An alias whose source is missing or is itself an alias is skipped —
|
||||
// unreachable here because _validateAliases already rejected it at
|
||||
// construction (kept for safety, like the Go and Rust ports).
|
||||
if (source != null) {
|
||||
map[entry.key] = Component.alias(entry.key, target, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _components = UnmodifiableMapView(map);
|
||||
}
|
||||
|
||||
/// Returns a name → [ColorDefinition] map for all defined colors, built
|
||||
/// lazily on first access, in definition order.
|
||||
Map<String, ColorDefinition> get colors => _colors ??= UnmodifiableMapView({
|
||||
for (final entry
|
||||
in (_data['colors'] as Map<String, Object?>? ?? const {}).entries)
|
||||
entry.key: ColorDefinition(entry.value as Map<String, Object?>),
|
||||
});
|
||||
|
||||
/// Returns a deep copy of the root SVG attributes from the definition,
|
||||
/// defaulting to an empty map. A fresh copy is made per call, keeping the
|
||||
/// JSON insertion order — attribute order is load-bearing for byte parity.
|
||||
Map<String, Object?> attributes() =>
|
||||
deepCopyJsonMap(_data['attributes'] as Map<String, Object?>? ?? const {});
|
||||
|
||||
/// Returns a deep copy of the underlying definition. A fresh copy is made
|
||||
/// per call, so mutating the result cannot corrupt this style.
|
||||
Map<String, Object?> definition() => deepCopyJsonMap(_data);
|
||||
|
||||
/// Verifies that every component declared via `extends` references an
|
||||
/// existing, non-alias component in the same `components` map — a cross-key
|
||||
/// constraint the JSON Schema cannot express. All failures are collected
|
||||
/// (in definition order, like the JS port), then one [StyleValidationError]
|
||||
/// is thrown.
|
||||
void _validateAliases() {
|
||||
final components = _data['components'] as Map<String, Object?>?;
|
||||
|
||||
if (components == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final errors = <ValidationErrorDetail>[];
|
||||
|
||||
for (final entry in components.entries) {
|
||||
final data = entry.value as Map<String, Object?>;
|
||||
|
||||
if (!_isAlias(data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final target = data['extends'] as String;
|
||||
final targetData = components[target] as Map<String, Object?>?;
|
||||
|
||||
if (targetData == null) {
|
||||
errors.add(ValidationErrorDetail(
|
||||
instancePath: '/components/${entry.key}/extends',
|
||||
message: 'references unknown component "$target"',
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_isAlias(targetData)) {
|
||||
errors.add(ValidationErrorDetail(
|
||||
instancePath: '/components/${entry.key}/extends',
|
||||
message: 'references alias "$target" — alias chains are not allowed',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.isNotEmpty) {
|
||||
throw StyleValidationError(errors);
|
||||
}
|
||||
}
|
||||
|
||||
// The discriminator between a base component and an alias, matching the JS
|
||||
// port's `'extends' in data`.
|
||||
static bool _isAlias(Map<String, Object?> data) =>
|
||||
data.containsKey('extends');
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/// Read-only view over a style definition's `canvas` block.
|
||||
library;
|
||||
|
||||
import 'element.dart';
|
||||
|
||||
/// The drawing area of a style: the `viewBox` dimensions and the top-level
|
||||
/// element list.
|
||||
class Canvas {
|
||||
final Map<String, Object?> _data;
|
||||
List<ElementNode>? _elements;
|
||||
|
||||
Canvas(Map<String, Object?> data) : _data = data;
|
||||
|
||||
/// Returns the canvas width — the `width` value of the SVG `viewBox`.
|
||||
double get width => (_data['width'] as num).toDouble();
|
||||
|
||||
/// Returns the canvas height — the `height` value of the SVG `viewBox`.
|
||||
double get height => (_data['height'] as num).toDouble();
|
||||
|
||||
/// Returns the top-level elements rendered onto the canvas, lazily wrapped
|
||||
/// as [ElementNode] instances on first access.
|
||||
List<ElementNode> get elements => _elements ??= List.unmodifiable(
|
||||
(_data['elements'] as List<Object?>)
|
||||
.map((element) => ElementNode(element as Map<String, Object?>)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/// Read-only view over an entry in a style definition's `colors` block.
|
||||
library;
|
||||
|
||||
/// A named color of a style: the candidate values plus the contrast and
|
||||
/// exclusion constraints the resolver applies. The color math itself lives in
|
||||
/// the public `Color` utility class.
|
||||
///
|
||||
/// Named `ColorDefinition` (not `Color`) to keep it distinct from the public
|
||||
/// color-math class — the renderer imports both.
|
||||
class ColorDefinition {
|
||||
final Map<String, Object?> _data;
|
||||
List<String>? _values;
|
||||
List<String>? _notEqualTo;
|
||||
|
||||
ColorDefinition(Map<String, Object?> data) : _data = data;
|
||||
|
||||
/// Returns the candidate color values, in definition order.
|
||||
List<String> values() => _values ??= List.unmodifiable(
|
||||
(_data['values'] as List<Object?>).cast<String>(),
|
||||
);
|
||||
|
||||
/// Returns the names of the colors that the resolver should avoid picking,
|
||||
/// or an empty list when the field is unset.
|
||||
List<String> notEqualTo() => _notEqualTo ??= List.unmodifiable(
|
||||
(_data['notEqualTo'] as List<Object?>? ?? const []).cast<String>(),
|
||||
);
|
||||
|
||||
/// Returns the name of another color that this one should contrast
|
||||
/// against, or `null` when no contrast constraint is defined.
|
||||
String? contrastTo() => _data['contrastTo'] as String?;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/// Read-only view over an entry in a style definition's `components` block.
|
||||
library;
|
||||
|
||||
import 'dart:collection';
|
||||
|
||||
import '../range.dart';
|
||||
import 'component_translate.dart';
|
||||
import 'component_variant.dart';
|
||||
|
||||
/// An entry in a style definition's `components` block.
|
||||
///
|
||||
/// An entry is either a base component with its own dimensions and variants
|
||||
/// or an alias declared via `extends`. Aliases are pure references — they
|
||||
/// share the source component's data and inherit dimensions, variants, and
|
||||
/// all transforms from it, while keeping their own [name]. The
|
||||
/// split matters for parity: PRNG keys use the alias name, user-option lookup
|
||||
/// and `<use>` def IDs use [sourceName].
|
||||
class Component {
|
||||
final String _name;
|
||||
final String? _extendsName;
|
||||
final _ComponentData _data;
|
||||
|
||||
/// Creates a base component from its decoded definition entry.
|
||||
Component(String name, Map<String, Object?> data)
|
||||
: _name = name,
|
||||
_extendsName = null,
|
||||
_data = _ComponentData(data);
|
||||
|
||||
/// Creates an alias of [source], sharing the source component's data.
|
||||
Component.alias(String name, String extendsName, Component source)
|
||||
: _name = name,
|
||||
_extendsName = extendsName,
|
||||
_data = source._data;
|
||||
|
||||
/// Returns the entry's own name as declared in the style definition. For
|
||||
/// aliases this is the alias key, not the source component's name (use
|
||||
/// [sourceName] for the canonical user-option key prefix).
|
||||
String get name => _name;
|
||||
|
||||
/// Returns the source component name when this entry is an alias, or
|
||||
/// `null` for a base component.
|
||||
String? get extendsName => _extendsName;
|
||||
|
||||
/// Returns the canonical user-option key prefix: the source component's
|
||||
/// name when this entry is an alias, otherwise the entry's own name.
|
||||
String get sourceName => _extendsName ?? _name;
|
||||
|
||||
/// Returns the component's intrinsic width in canvas coordinates. For
|
||||
/// aliases the source component's width is returned.
|
||||
double get width => _data.width;
|
||||
|
||||
/// Returns the component's intrinsic height in canvas coordinates. For
|
||||
/// aliases the source component's height is returned.
|
||||
double get height => _data.height;
|
||||
|
||||
/// Returns the probability (0–100) that this component is rendered.
|
||||
/// Aliases delegate to the source; defaults to 100 (always visible).
|
||||
double probability() => _data.probability ?? 100;
|
||||
|
||||
/// Returns the rotation range, or `null` when unset.
|
||||
/// Aliases delegate to the source.
|
||||
Range? rotate() => _data.rotate;
|
||||
|
||||
/// Returns the scale range, or `null` when unset.
|
||||
/// Aliases delegate to the source.
|
||||
Range? scale() => _data.scale;
|
||||
|
||||
/// Returns the translate descriptor. Aliases delegate to the source.
|
||||
ComponentTranslate translate() => _data.translate;
|
||||
|
||||
/// Returns a name → [ComponentVariant] map for all defined variants, in
|
||||
/// definition order. Aliases delegate to the source component's variants.
|
||||
Map<String, ComponentVariant> variants() => _data.variants();
|
||||
}
|
||||
|
||||
/// The intrinsic data of a base component, shared by its aliases (the Go
|
||||
/// port's `componentData`; the Rust port shares it via `Arc`).
|
||||
class _ComponentData {
|
||||
final Map<String, Object?> _raw;
|
||||
final double width;
|
||||
final double height;
|
||||
final double? probability;
|
||||
final Range? rotate;
|
||||
final Range? scale;
|
||||
final ComponentTranslate translate;
|
||||
Map<String, ComponentVariant>? _variants;
|
||||
|
||||
_ComponentData(Map<String, Object?> raw)
|
||||
: _raw = raw,
|
||||
width = (raw['width'] as num).toDouble(),
|
||||
height = (raw['height'] as num).toDouble(),
|
||||
probability = raw['probability'] == null
|
||||
? null
|
||||
: (raw['probability'] as num).toDouble(),
|
||||
rotate = rangeFromDefinition(raw['rotate']),
|
||||
scale = rangeFromDefinition(raw['scale']),
|
||||
translate = ComponentTranslate(
|
||||
raw['translate'] as Map<String, Object?>? ?? const {},
|
||||
);
|
||||
|
||||
/// Returns the variants map, lazily built on first access. The map keeps
|
||||
/// definition order — it drives the weighted pick when the user sets no
|
||||
/// `…Variant` option.
|
||||
Map<String, ComponentVariant> variants() =>
|
||||
_variants ??= UnmodifiableMapView({
|
||||
for (final entry
|
||||
in (_raw['variants'] as Map<String, Object?>? ?? const {}).entries)
|
||||
entry.key: ComponentVariant(entry.value as Map<String, Object?>),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/// Read-only view over a component's `translate` block.
|
||||
library;
|
||||
|
||||
import '../range.dart';
|
||||
|
||||
/// The X and Y offset ranges of a component's `translate` block. Both are
|
||||
/// `null` when the corresponding axis is unset.
|
||||
class ComponentTranslate {
|
||||
final Range? _x;
|
||||
final Range? _y;
|
||||
|
||||
ComponentTranslate(Map<String, Object?> data)
|
||||
: _x = rangeFromDefinition(data['x']),
|
||||
_y = rangeFromDefinition(data['y']);
|
||||
|
||||
/// Returns the X offset range (percent of the component's width), or
|
||||
/// `null` when unset.
|
||||
Range? x() => _x;
|
||||
|
||||
/// Returns the Y offset range (percent of the component's height), or
|
||||
/// `null` when unset.
|
||||
Range? y() => _y;
|
||||
}
|
||||
|
||||
/// Builds a [Range] from a style definition value, or returns `null` when the
|
||||
/// field is unset.
|
||||
///
|
||||
/// In a style definition a range is always a `{min, max, step?}` object (the
|
||||
/// definition schema pins that shape) — the bare-number and `[min, max]`
|
||||
/// array shorthands exist only in the user-facing options and are normalized
|
||||
/// by `Options`, not here.
|
||||
Range? rangeFromDefinition(Object? value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final data = value as Map<String, Object?>;
|
||||
final step = data['step'];
|
||||
|
||||
return Range(
|
||||
min: (data['min'] as num).toDouble(),
|
||||
max: (data['max'] as num).toDouble(),
|
||||
step: step == null ? null : (step as num).toDouble(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/// Read-only view over an entry in a component's `variants` block.
|
||||
library;
|
||||
|
||||
import 'element.dart';
|
||||
|
||||
/// A single variant of a component: the element subtree it renders and its
|
||||
/// weighted-pick weight.
|
||||
class ComponentVariant {
|
||||
final Map<String, Object?> _data;
|
||||
List<ElementNode>? _elements;
|
||||
|
||||
ComponentVariant(Map<String, Object?> data) : _data = data;
|
||||
|
||||
/// Returns the variant's elements, lazily wrapped as [ElementNode]
|
||||
/// instances on first access.
|
||||
List<ElementNode> elements() => _elements ??= List.unmodifiable(
|
||||
(_data['elements'] as List<Object?>)
|
||||
.map((element) => ElementNode(element as Map<String, Object?>)),
|
||||
);
|
||||
|
||||
/// Returns the weighted-pick weight for this variant, defaulting to `1`.
|
||||
double weight() {
|
||||
final weight = _data['weight'];
|
||||
|
||||
return weight == null ? 1 : (weight as num).toDouble();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/// Read-only view over a single render-tree node from a style definition.
|
||||
library;
|
||||
|
||||
/// A single render-tree element from a style definition.
|
||||
///
|
||||
/// The same node type covers SVG elements, text, and component references —
|
||||
/// [type] discriminates between them. The node is a thin view over the
|
||||
/// decoded definition JSON; the data is shared, never copied.
|
||||
class ElementNode {
|
||||
final Map<String, Object?> _data;
|
||||
List<ElementNode>? _children;
|
||||
|
||||
ElementNode(Map<String, Object?> data) : _data = data;
|
||||
|
||||
/// Returns the element type discriminator
|
||||
/// (`'element'`, `'text'` or `'component'`).
|
||||
String get type => _data['type'] as String;
|
||||
|
||||
/// Returns the element's tag/component name, or `null` for elements that
|
||||
/// don't have one.
|
||||
String? get name => _data['name'] as String?;
|
||||
|
||||
/// Returns the element's textual value (for `text` elements): a literal
|
||||
/// [String], a `{'type': 'variable', 'name': ...}` reference map, or `null`
|
||||
/// when not applicable. The value is passed through raw, like the JS port.
|
||||
Object? get value => _data['value'];
|
||||
|
||||
/// Returns the element's raw attribute map, or `null` when no attributes
|
||||
/// are defined.
|
||||
///
|
||||
/// The map is the decoded definition JSON itself, in JSON insertion order —
|
||||
/// SVG attributes must render in the order they were declared, so the order
|
||||
/// is load-bearing (the JS/Rust/Go ports rely on `Map`/`IndexMap`/an
|
||||
/// ordered list for the same reason; Dart maps preserve insertion order
|
||||
/// natively). Values are literal strings or
|
||||
/// `{'type': 'color' | 'variable', 'name': ...}` reference maps.
|
||||
Map<String, Object?>? get attributes =>
|
||||
_data['attributes'] as Map<String, Object?>?;
|
||||
|
||||
/// Returns the element's children, lazily wrapped as [ElementNode]
|
||||
/// instances on first access. Defaults to an empty list.
|
||||
List<ElementNode> get children => _children ??= List.unmodifiable(
|
||||
(_data['children'] as List<Object?>? ?? const [])
|
||||
.map((child) => ElementNode(child as Map<String, Object?>)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/// Read-only view over a style definition's `meta` block.
|
||||
library;
|
||||
|
||||
import 'meta_creator.dart';
|
||||
import 'meta_license.dart';
|
||||
import 'meta_source.dart';
|
||||
|
||||
/// The metadata of a style, exposing the license, creator, and source
|
||||
/// descriptors. Each descriptor defaults to an empty block when the style
|
||||
/// definition omits the field, so callers never need a null check.
|
||||
class Meta {
|
||||
final Map<String, Object?> _data;
|
||||
MetaLicense? _license;
|
||||
MetaCreator? _creator;
|
||||
MetaSource? _source;
|
||||
|
||||
Meta(Map<String, Object?> data) : _data = data;
|
||||
|
||||
/// Returns the license descriptor, lazily constructed on first access and
|
||||
/// defaulting to an empty block when the style definition omits the field.
|
||||
MetaLicense license() => _license ??=
|
||||
MetaLicense(_data['license'] as Map<String, Object?>? ?? const {});
|
||||
|
||||
/// Returns the creator descriptor, lazily constructed on first access and
|
||||
/// defaulting to an empty block when the style definition omits the field.
|
||||
MetaCreator creator() => _creator ??=
|
||||
MetaCreator(_data['creator'] as Map<String, Object?>? ?? const {});
|
||||
|
||||
/// Returns the source descriptor, lazily constructed on first access and
|
||||
/// defaulting to an empty block when the style definition omits the field.
|
||||
MetaSource source() => _source ??=
|
||||
MetaSource(_data['source'] as Map<String, Object?>? ?? const {});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/// Read-only view over the `meta.creator` block of a style definition.
|
||||
library;
|
||||
|
||||
/// The creator descriptor of a style's metadata.
|
||||
///
|
||||
/// Accessors return `null` for absent fields, distinguishing them from fields
|
||||
/// explicitly set to the empty string — the license builder relies on that
|
||||
/// distinction (the JS nullish `?? 'Unknown'`; Go/Rust use `*string`/Option
|
||||
/// for the same reason).
|
||||
class MetaCreator {
|
||||
final Map<String, Object?> _data;
|
||||
|
||||
MetaCreator(Map<String, Object?> data) : _data = data;
|
||||
|
||||
/// Returns the creator's display name, or `null` when not set.
|
||||
String? name() => _data['name'] as String?;
|
||||
|
||||
/// Returns the creator's homepage URL, or `null` when not set.
|
||||
String? url() => _data['url'] as String?;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/// Read-only view over the `meta.license` block of a style definition.
|
||||
library;
|
||||
|
||||
/// The license descriptor of a style's metadata.
|
||||
///
|
||||
/// Accessors return `null` for absent fields, distinguishing them from fields
|
||||
/// explicitly set to the empty string, matching the JS nullish and the
|
||||
/// Go `*string` / Rust Option logic. Never coalesce `''` to `null` here — the
|
||||
/// license builder treats the two differently.
|
||||
class MetaLicense {
|
||||
final Map<String, Object?> _data;
|
||||
|
||||
MetaLicense(Map<String, Object?> data) : _data = data;
|
||||
|
||||
/// Returns the license name (e.g. `'CC BY 4.0'`), or `null` when not set.
|
||||
String? name() => _data['name'] as String?;
|
||||
|
||||
/// Returns the license URL, or `null` when not set.
|
||||
String? url() => _data['url'] as String?;
|
||||
|
||||
/// Returns the full license text, or `null` when not set.
|
||||
String? text() => _data['text'] as String?;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/// Read-only view over the `meta.source` block of a style definition.
|
||||
library;
|
||||
|
||||
/// The source descriptor of a style's metadata.
|
||||
///
|
||||
/// Accessors return `null` for absent fields, distinguishing them from fields
|
||||
/// explicitly set to the empty string — the license builder relies on that
|
||||
/// distinction.
|
||||
class MetaSource {
|
||||
final Map<String, Object?> _data;
|
||||
|
||||
MetaSource(Map<String, Object?> data) : _data = data;
|
||||
|
||||
/// Returns the source name (e.g. the original work title), or `null` when
|
||||
/// not set.
|
||||
String? name() => _data['name'] as String?;
|
||||
|
||||
/// Returns the URL of the source, or `null` when not set.
|
||||
String? url() => _data['url'] as String?;
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
/// Color helpers used by the renderer and the option resolver: hex
|
||||
/// normalization, WCAG relative luminance, contrast sorting, and exclusion
|
||||
/// filtering. Exported so consumers can reproduce the engine's color math,
|
||||
/// mirroring the `Color` utility in the JS core.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// Color helpers used by the renderer and the option resolver.
|
||||
abstract final class Color {
|
||||
/// Normalizes any hex format to 6- or 8-digit lowercase with `#` prefix.
|
||||
static String toHex(String hex) {
|
||||
// JS `replace(/^#/, '')` strips at most one leading `#`.
|
||||
final h = (hex.startsWith('#') ? hex.substring(1) : hex).toLowerCase();
|
||||
|
||||
if (h.length == 3) {
|
||||
return '#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}';
|
||||
}
|
||||
|
||||
if (h.length == 4) {
|
||||
return '#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}';
|
||||
}
|
||||
|
||||
return '#$h';
|
||||
}
|
||||
|
||||
/// Like [toHex], but strips the alpha channel and always returns 6-digit
|
||||
/// hex.
|
||||
static String toRgbHex(String hex) {
|
||||
final h = toHex(hex);
|
||||
|
||||
// JS `slice(0, 7)` counts UTF-16 code units; Dart's `length` and
|
||||
// `substring` share exactly those semantics, so unlike the Go and Rust
|
||||
// ports no conversion is needed here.
|
||||
return h.length > 7 ? h.substring(0, 7) : h;
|
||||
}
|
||||
|
||||
/// Parses a hex color into an `[r, g, b]` triple of 8-bit channel values.
|
||||
/// Malformed channels fall back to `0`.
|
||||
static List<int> parseHex(String hex) {
|
||||
final h = toHex(hex).substring(1);
|
||||
|
||||
return [_channel(h, 0), _channel(h, 2), _channel(h, 4)];
|
||||
}
|
||||
|
||||
/// WCAG 2.1 relative luminance with sRGB linearization.
|
||||
///
|
||||
/// See https://www.w3.org/WAI/GL/wiki/Relative_luminance
|
||||
static double luminance(String hex) {
|
||||
final rgb = parseHex(hex);
|
||||
final linearR = _linearize(rgb[0]);
|
||||
final linearG = _linearize(rgb[1]);
|
||||
final linearB = _linearize(rgb[2]);
|
||||
|
||||
// Each product and addition is a separate double operation in JS
|
||||
// evaluation order. Dart rounds every floating-point operation to a
|
||||
// 64-bit double on both the VM and the web, so no FMA-contraction guard
|
||||
// is needed (Go forces explicit float64 conversions here) — but keep the
|
||||
// operations separate anyway.
|
||||
final r = 0.2126 * linearR;
|
||||
final g = 0.7152 * linearG;
|
||||
final b = 0.0722 * linearB;
|
||||
|
||||
return r + g + b;
|
||||
}
|
||||
|
||||
/// Returns a new list sorted by descending contrast against the reference
|
||||
/// color.
|
||||
///
|
||||
/// See https://www.w3.org/WAI/GL/wiki/Contrast_ratio
|
||||
static List<String> sortByContrast(
|
||||
List<String> candidates,
|
||||
String refColor,
|
||||
) {
|
||||
final refLum = luminance(refColor);
|
||||
|
||||
final withRatio = <({int index, String color, double ratio})>[];
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
final lum = luminance(candidates[i]);
|
||||
final ratio =
|
||||
(math.max(lum, refLum) + 0.05) / (math.min(lum, refLum) + 0.05);
|
||||
|
||||
withRatio.add((index: i, color: candidates[i], ratio: ratio));
|
||||
}
|
||||
|
||||
// JS `Array#sort` is stable; Dart's `List.sort` is not. Decorating each
|
||||
// entry with its original index and tie-breaking ascending reproduces a
|
||||
// stable descending sort, matching the JS `b.ratio - a.ratio` comparator.
|
||||
withRatio.sort((a, b) {
|
||||
final byRatio = b.ratio.compareTo(a.ratio);
|
||||
|
||||
return byRatio != 0 ? byRatio : a.index.compareTo(b.index);
|
||||
});
|
||||
|
||||
return [for (final entry in withRatio) entry.color];
|
||||
}
|
||||
|
||||
/// Returns a new list with excluded colors removed. Falls back to the
|
||||
/// original candidates when filtering would empty the list.
|
||||
static List<String> filterNotEqualTo(
|
||||
List<String> candidates,
|
||||
List<String> excluded,
|
||||
) {
|
||||
// Comparison via [toRgbHex] ignores the alpha channel.
|
||||
final normalized = {for (final color in excluded) toRgbHex(color)};
|
||||
final filtered = [
|
||||
for (final color in candidates)
|
||||
if (!normalized.contains(toRgbHex(color))) color,
|
||||
];
|
||||
|
||||
return filtered.isNotEmpty ? filtered : List.of(candidates);
|
||||
}
|
||||
|
||||
/// Parses two hex digits at [start] into a channel value, falling back to
|
||||
/// `0` like the Go and Rust ports. JS `parseInt` instead prefix-parses
|
||||
/// malformed input (`'e'` → 14, `'-f'` → -15), so results diverge for hex
|
||||
/// strings the schema's color pattern would reject — unreachable through
|
||||
/// validated options, but observable via the exported [Color] helpers.
|
||||
static int _channel(String digits, int start) {
|
||||
if (start + 2 > digits.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final value = int.tryParse(digits.substring(start, start + 2), radix: 16);
|
||||
|
||||
return value == null || value < 0 ? 0 : value;
|
||||
}
|
||||
|
||||
/// Linear-light value for every 8-bit sRGB channel value, precomputed from
|
||||
/// `s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4` with
|
||||
/// `s = channel / 255`.
|
||||
///
|
||||
/// A lookup table because `pow` is not required to be correctly rounded:
|
||||
/// results differ between JS engines and the math libraries used by the
|
||||
/// other language ports, which would break cross-language byte parity.
|
||||
/// Values are the JS reference outputs.
|
||||
static const List<double> _linearized = [
|
||||
0.0,
|
||||
0.0003035269835488375,
|
||||
0.000607053967097675,
|
||||
0.0009105809506465125,
|
||||
0.00121410793419535,
|
||||
0.0015176349177441874,
|
||||
0.001821161901293025,
|
||||
0.0021246888848418626,
|
||||
0.0024282158683907,
|
||||
0.0027317428519395373,
|
||||
0.003035269835488375,
|
||||
0.003346535763899161,
|
||||
0.003676507324047436,
|
||||
0.004024717018496307,
|
||||
0.004391442037410293,
|
||||
0.004776953480693729,
|
||||
0.005181516702338386,
|
||||
0.005605391624202723,
|
||||
0.006048833022857055,
|
||||
0.006512090792594474,
|
||||
0.006995410187265387,
|
||||
0.007499032043226175,
|
||||
0.008023192985384994,
|
||||
0.008568125618069307,
|
||||
0.009134058702220787,
|
||||
0.009721217320237847,
|
||||
0.010329823029626938,
|
||||
0.010960094006488246,
|
||||
0.011612245179743887,
|
||||
0.012286488356915872,
|
||||
0.012983032342173012,
|
||||
0.013702083047289686,
|
||||
0.014443843596092545,
|
||||
0.01520851442291271,
|
||||
0.01599629336550963,
|
||||
0.016807375752887384,
|
||||
0.017641954488384078,
|
||||
0.018500220128379697,
|
||||
0.019382360956935723,
|
||||
0.0202885630566524,
|
||||
0.021219010376003558,
|
||||
0.02217388479338738,
|
||||
0.02315336617811041,
|
||||
0.024157632448504756,
|
||||
0.025186859627361627,
|
||||
0.026241221894849898,
|
||||
0.027320891639074897,
|
||||
0.028426039504420793,
|
||||
0.0295568344378088,
|
||||
0.030713443732993635,
|
||||
0.03189603307301153,
|
||||
0.033104766570885055,
|
||||
0.03433980680868217,
|
||||
0.03560131487502034,
|
||||
0.03688945040110004,
|
||||
0.0382043715953465,
|
||||
0.03954623527673283,
|
||||
0.04091519690685319,
|
||||
0.042311410620809675,
|
||||
0.043735029256973465,
|
||||
0.04518620438567554,
|
||||
0.0466650863368801,
|
||||
0.048171824226889426,
|
||||
0.04970656598412723,
|
||||
0.05126945837404324,
|
||||
0.052860647023180246,
|
||||
0.05448027644244237,
|
||||
0.05612849004960009,
|
||||
0.05780543019106723,
|
||||
0.0595112381629812,
|
||||
0.06124605423161761,
|
||||
0.06301001765316767,
|
||||
0.06480326669290577,
|
||||
0.06662593864377289,
|
||||
0.06847816984440017,
|
||||
0.07036009569659588,
|
||||
0.07227185068231748,
|
||||
0.07421356838014963,
|
||||
0.07618538148130785,
|
||||
0.07818742180518633,
|
||||
0.08021982031446831,
|
||||
0.0822827071298148,
|
||||
0.08437621154414882,
|
||||
0.08650046203654976,
|
||||
0.08865558628577294,
|
||||
0.09084171118340767,
|
||||
0.09305896284668747,
|
||||
0.0953074666309647,
|
||||
0.09758734714186246,
|
||||
0.09989872824711389,
|
||||
0.1022417330881013,
|
||||
0.10461648409110419,
|
||||
0.10702310297826761,
|
||||
0.10946171077829933,
|
||||
0.1119324278369056,
|
||||
0.11443537382697373,
|
||||
0.11697066775851084,
|
||||
0.11953842798834562,
|
||||
0.12213877222960187,
|
||||
0.12477181756095049,
|
||||
0.12743768043564743,
|
||||
0.1301364766903643,
|
||||
0.13286832155381798,
|
||||
0.13563332965520566,
|
||||
0.13843161503245183,
|
||||
0.14126329114027164,
|
||||
0.14412847085805777,
|
||||
0.14702726649759498,
|
||||
0.14995978981060856,
|
||||
0.15292615199615017,
|
||||
0.1559264637078274,
|
||||
0.1589608350608804,
|
||||
0.16202937563911096,
|
||||
0.1651321945016676,
|
||||
0.16826940018969075,
|
||||
0.1714411007328226,
|
||||
0.17464740365558504,
|
||||
0.17788841598362914,
|
||||
0.18116424424986022,
|
||||
0.184474994500441,
|
||||
0.18782077230067787,
|
||||
0.1912016827407914,
|
||||
0.19461783044157582,
|
||||
0.19806931955994886,
|
||||
0.20155625379439707,
|
||||
0.20507873639031693,
|
||||
0.20863687014525575,
|
||||
0.21223075741405523,
|
||||
0.21586050011389923,
|
||||
0.21952619972926923,
|
||||
0.2232279573168085,
|
||||
0.22696587351009836,
|
||||
0.23074004852434915,
|
||||
0.23455058216100522,
|
||||
0.238397573812271,
|
||||
0.24228112246555486,
|
||||
0.24620132670783548,
|
||||
0.25015828472995344,
|
||||
0.25415209433082675,
|
||||
0.2581828529215958,
|
||||
0.26225065752969623,
|
||||
0.26635560480286247,
|
||||
0.2704977910130658,
|
||||
0.27467731206038465,
|
||||
0.2788942634768104,
|
||||
0.2831487404299921,
|
||||
0.2874408377269175,
|
||||
0.29177064981753587,
|
||||
0.2961382707983211,
|
||||
0.3005437944157765,
|
||||
0.3049873140698863,
|
||||
0.30946892281750854,
|
||||
0.31398871337571754,
|
||||
0.31854677812509186,
|
||||
0.32314320911295075,
|
||||
0.3277780980565422,
|
||||
0.33245153634617935,
|
||||
0.33716361504833037,
|
||||
0.341914424908661,
|
||||
0.3467040563550296,
|
||||
0.35153259950043936,
|
||||
0.3564001441459435,
|
||||
0.3613067797835095,
|
||||
0.3662525955988395,
|
||||
0.3712376804741491,
|
||||
0.37626212299090644,
|
||||
0.3813260114325301,
|
||||
0.386429433787049,
|
||||
0.39157247774972326,
|
||||
0.39675523072562685,
|
||||
0.40197777983219574,
|
||||
0.4072402119017367,
|
||||
0.41254261348390375,
|
||||
0.4178850708481375,
|
||||
0.4232676699860717,
|
||||
0.4286904966139067,
|
||||
0.4341536361747489,
|
||||
0.4396571738409188,
|
||||
0.44520119451622786,
|
||||
0.45078578283822346,
|
||||
0.45641102318040466,
|
||||
0.4620769996544071,
|
||||
0.467783796112159,
|
||||
0.47353149614800955,
|
||||
0.4793201831008268,
|
||||
0.4851499400560704,
|
||||
0.4910208498478356,
|
||||
0.4969329950608704,
|
||||
0.5028864580325687,
|
||||
0.5088813208549338,
|
||||
0.5149176653765214,
|
||||
0.5209955732043543,
|
||||
0.5271151257058131,
|
||||
0.5332764040105052,
|
||||
0.5394794890121071,
|
||||
0.5457244613701866,
|
||||
0.5520114015120001,
|
||||
0.5583403896342679,
|
||||
0.5647115057049292,
|
||||
0.5711248294648731,
|
||||
0.5775804404296506,
|
||||
0.5840784178911641,
|
||||
0.5906188409193369,
|
||||
0.5972017883637634,
|
||||
0.6038273388553378,
|
||||
0.6104955708078648,
|
||||
0.6172065624196511,
|
||||
0.6239603916750761,
|
||||
0.6307571363461468,
|
||||
0.6375968739940326,
|
||||
0.6444796819705821,
|
||||
0.6514056374198242,
|
||||
0.6583748172794486,
|
||||
0.665387298282272,
|
||||
0.6724431569576875,
|
||||
0.6795424696330938,
|
||||
0.6866853124353134,
|
||||
0.6938717612919899,
|
||||
0.7011018919329731,
|
||||
0.7083757798916868,
|
||||
0.7156935005064807,
|
||||
0.7230551289219693,
|
||||
0.7304607400903537,
|
||||
0.7379104087727308,
|
||||
0.7454042095403874,
|
||||
0.7529422167760779,
|
||||
0.7605245046752924,
|
||||
0.7681511472475071,
|
||||
0.7758222183174236,
|
||||
0.7835377915261935,
|
||||
0.7912979403326302,
|
||||
0.799102738014409,
|
||||
0.8069522576692516,
|
||||
0.8148465722161012,
|
||||
0.8227857543962835,
|
||||
0.8307698767746546,
|
||||
0.83879901174074,
|
||||
0.846873231509858,
|
||||
0.8549926081242338,
|
||||
0.8631572134541023,
|
||||
0.8713671191987973,
|
||||
0.8796223968878317,
|
||||
0.8879231178819663,
|
||||
0.8962693533742664,
|
||||
0.9046611743911496,
|
||||
0.9130986517934192,
|
||||
0.9215818562772946,
|
||||
0.9301108583754237,
|
||||
0.938685728457888,
|
||||
0.9473065367331999,
|
||||
0.9559733532492861,
|
||||
0.9646862478944651,
|
||||
0.9734452903984125,
|
||||
0.9822505503331171,
|
||||
0.9911020971138298,
|
||||
1,
|
||||
];
|
||||
|
||||
/// Converts an 8-bit sRGB channel value into linear-light space.
|
||||
static double _linearize(int channel) {
|
||||
return _linearized[channel];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/// A `structuredClone` equivalent for decoded-JSON value trees, shared by the
|
||||
/// model and resolver layers so the cloning contract lives in one place.
|
||||
library;
|
||||
|
||||
/// Deep-copies a decoded-JSON [value]: maps and lists are cloned recursively
|
||||
/// with insertion order preserved, immutable scalars are returned as-is.
|
||||
///
|
||||
/// This mirrors the JS port's `structuredClone` for JSON-shaped data, so that
|
||||
/// later mutation of a caller's input (or of a returned snapshot) cannot leak
|
||||
/// into or out of the engine's internal state. The preserved insertion order
|
||||
/// is load-bearing: SVG attributes, `<defs>` entries, and the resolved-options
|
||||
/// snapshot all emit in source/first-recorded order.
|
||||
Object? deepCopyJsonValue(Object? value) {
|
||||
if (value is Map) {
|
||||
return <String, Object?>{
|
||||
for (final entry in value.entries)
|
||||
entry.key as String: deepCopyJsonValue(entry.value),
|
||||
};
|
||||
}
|
||||
|
||||
if (value is List) {
|
||||
return <Object?>[for (final item in value) deepCopyJsonValue(item)];
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Deep-copies a decoded-JSON map, preserving insertion order.
|
||||
Map<String, Object?> deepCopyJsonMap(Map<String, Object?> map) =>
|
||||
deepCopyJsonValue(map) as Map<String, Object?>;
|
||||
@@ -0,0 +1,69 @@
|
||||
/// Derives display initials from a seed string.
|
||||
///
|
||||
/// Words are split by Unicode letter/mark classes (`\p{L}`, `\p{M}`) so the
|
||||
/// result matches the JS, PHP, Python, Rust and Go ports for accented and
|
||||
/// non-Latin input. See https://www.regular-expressions.info/unicode.html
|
||||
library;
|
||||
|
||||
import 'uppercase.dart';
|
||||
|
||||
/// `@…` — the entire suffix (e.g. an email domain), stripped so the local
|
||||
/// part yields one word. `dotAll` makes `.` match line terminators too, so
|
||||
/// the whole tail is removed — matching the dotall `@.*` strip in every port.
|
||||
final _atSuffix = RegExp('@.*', dotAll: true);
|
||||
|
||||
/// Apostrophes and accents that should not break a word (e.g. `l'eau`,
|
||||
/// `d´or`): U+0060, U+00B4, U+0027, U+02BC.
|
||||
final _apostrophes = RegExp("[`´'ʼ]");
|
||||
|
||||
/// A word: a letter followed by any letters or combining marks.
|
||||
final _word = RegExp(r'\p{L}[\p{L}\p{M}]*', unicode: true);
|
||||
|
||||
/// The first one or two letter+marks units of a word.
|
||||
final _oneOrTwoUnits = RegExp(r'^(?:\p{L}\p{M}*){1,2}', unicode: true);
|
||||
|
||||
/// The first single letter+marks unit of a word.
|
||||
final _oneUnit = RegExp(r'^(?:\p{L}\p{M}*)', unicode: true);
|
||||
|
||||
/// Returns one or two uppercase initials for the given seed. By default
|
||||
/// strips `@...` so email addresses yield a single initial instead of being
|
||||
/// treated as two words.
|
||||
///
|
||||
/// Uppercasing goes through [jsToUpperCase] for full Unicode case mapping
|
||||
/// (ß→SS, fi→FI): Dart's `String.toUpperCase` only maps 1:1 on the VM, which
|
||||
/// would diverge from the JS reference — and from this package's own dart2js
|
||||
/// build (Go needed `golang.org/x/text/cases` for the same reason).
|
||||
String initialsFromSeed(String seed, [bool discardAtSymbol = true]) {
|
||||
var input = seed;
|
||||
|
||||
if (discardAtSymbol) {
|
||||
// JS `replace(/@.*/s, '')` has no `g` flag — only the first match. With
|
||||
// dotall, `@.*` reaches the end of the string anyway.
|
||||
input = seed.replaceFirst(_atSuffix, '');
|
||||
}
|
||||
|
||||
input = input.replaceAll(_apostrophes, '');
|
||||
|
||||
final matches = _word.allMatches(input).toList();
|
||||
|
||||
if (matches.isEmpty) {
|
||||
// Stripping the @ suffix left no words at all (e.g. a seed starting with
|
||||
// `@`) — retry once on the full seed.
|
||||
return discardAtSymbol ? initialsFromSeed(seed, false) : '';
|
||||
}
|
||||
|
||||
if (matches.length == 1) {
|
||||
final match = _oneOrTwoUnits.firstMatch(matches[0][0]!);
|
||||
|
||||
return match != null ? jsToUpperCase(match[0]!) : '';
|
||||
}
|
||||
|
||||
final first = _oneUnit.firstMatch(matches.first[0]!);
|
||||
final last = _oneUnit.firstMatch(matches.last[0]!);
|
||||
|
||||
if (first == null || last == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return jsToUpperCase(first[0]! + last[0]!);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/// Builds attribution strings and embedded RDF/Dublin Core metadata from a
|
||||
/// style's meta block. Mirrors the JS, PHP, Python, Go and Rust ports,
|
||||
/// including the nullish creator fallback and the empty-string-as-absent
|
||||
/// treatment.
|
||||
library;
|
||||
|
||||
import '../style/meta.dart';
|
||||
import 'xml.dart';
|
||||
|
||||
/// Returns `true` when [value] is absent or empty, mirroring the JS falsy
|
||||
/// checks (`!sourceName` is true for both a missing field and `''`).
|
||||
bool _unset(String? value) => value == null || value.isEmpty;
|
||||
|
||||
/// Returns a single-line attribution string suitable for `<title>` or
|
||||
/// `<desc>` content, or an empty string when no attribution data is
|
||||
/// available. The typographic quotes (U+201C/U+201D) are part of the
|
||||
/// cross-language byte-parity contract.
|
||||
String licenseText(Meta meta) {
|
||||
final sourceName = meta.source().name();
|
||||
final sourceUrl = meta.source().url();
|
||||
final creatorName = meta.creator().name();
|
||||
final licenseName = meta.license().name();
|
||||
final licenseUrl = meta.license().url();
|
||||
|
||||
if (_unset(sourceName) && _unset(creatorName) && _unset(licenseName)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var title = _unset(sourceName) ? 'Design' : '“$sourceName”';
|
||||
|
||||
if (!_unset(sourceUrl)) {
|
||||
title += ' ($sourceUrl)';
|
||||
}
|
||||
|
||||
// JS uses `creatorName ?? 'Unknown'` (nullish): an empty creator name stays
|
||||
// empty; only a missing one becomes "Unknown".
|
||||
final creator = '“${creatorName ?? 'Unknown'}”';
|
||||
|
||||
var result = '';
|
||||
|
||||
// Skip the "Remix of" prefix for MIT-licensed or DiceBear-original styles.
|
||||
if (licenseName != 'MIT' &&
|
||||
creatorName != 'DiceBear' &&
|
||||
!_unset(sourceName)) {
|
||||
result += 'Remix of ';
|
||||
}
|
||||
|
||||
result += '$title by $creator';
|
||||
|
||||
if (!_unset(licenseName)) {
|
||||
result += ', licensed under “$licenseName”';
|
||||
|
||||
if (!_unset(licenseUrl)) {
|
||||
result += ' ($licenseUrl)';
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Builds an embedded `<metadata>` block with Dublin Core terms describing
|
||||
/// the style's source, creator, license, and rights statement. Returns an
|
||||
/// empty string when no metadata fields are populated.
|
||||
String licenseXml(Meta meta) {
|
||||
final title = meta.source().name();
|
||||
final creatorName = meta.creator().name();
|
||||
final sourceUrl = meta.source().url();
|
||||
final licenseUrl = meta.license().url();
|
||||
final rights = licenseText(meta);
|
||||
|
||||
if (_unset(title) &&
|
||||
_unset(creatorName) &&
|
||||
_unset(sourceUrl) &&
|
||||
_unset(licenseUrl) &&
|
||||
rights.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final fields = StringBuffer();
|
||||
|
||||
if (!_unset(title)) {
|
||||
fields.write('<dc:title>${escapeXml(title!)}</dc:title>');
|
||||
}
|
||||
|
||||
if (!_unset(creatorName)) {
|
||||
fields.write('<dc:creator>${escapeXml(creatorName!)}</dc:creator>');
|
||||
}
|
||||
|
||||
if (!_unset(sourceUrl)) {
|
||||
fields.write(
|
||||
'<dc:source xsi:type="dcterms:URI">${escapeXml(sourceUrl!)}</dc:source>',
|
||||
);
|
||||
}
|
||||
|
||||
if (!_unset(licenseUrl)) {
|
||||
fields.write(
|
||||
'<dcterms:license xsi:type="dcterms:URI">${escapeXml(licenseUrl!)}'
|
||||
'</dcterms:license>',
|
||||
);
|
||||
}
|
||||
|
||||
if (rights.isNotEmpty) {
|
||||
fields.write('<dc:rights>${escapeXml(rights)}</dc:rights>');
|
||||
}
|
||||
|
||||
return '<metadata'
|
||||
' xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
' xmlns:dc="http://purl.org/dc/elements/1.1/"'
|
||||
' xmlns:dcterms="http://purl.org/dc/terms/">'
|
||||
'<rdf:RDF><rdf:Description>$fields</rdf:Description></rdf:RDF>'
|
||||
'</metadata>';
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/// Numeric helpers shared by the PRNG and the renderer: SVG number
|
||||
/// formatting and the JavaScript-compatible half-up rounding the rest of the
|
||||
/// engine depends on for cross-language parity.
|
||||
library;
|
||||
|
||||
/// Rounds half toward +∞, matching JavaScript's `Math.round`. Dart's
|
||||
/// `num.round()` rounds half away from zero, and the naive
|
||||
/// `(x + 0.5).floor()` over-rounds the largest double below 0.5; comparing
|
||||
/// the fractional part against 0.5 reproduces `Math.round` exactly.
|
||||
///
|
||||
/// Unlike Go (which needs `//go:noinline` here to forbid FMA contraction),
|
||||
/// Dart rounds every floating-point operation to a 64-bit double on both the
|
||||
/// VM and the web, so an expression like `value * 100000.0` cannot fuse with
|
||||
/// the subtraction below. Keep the multiply and the comparison as separate
|
||||
/// operations anyway — the parity fixture `1.999995 → "2"` is the canary.
|
||||
double roundHalfUp(double value) {
|
||||
final floor = value.floorToDouble();
|
||||
|
||||
if (value - floor < 0.5) {
|
||||
return floor;
|
||||
}
|
||||
|
||||
return floor + 1.0;
|
||||
}
|
||||
|
||||
/// Formats a number for SVG output, rounded to at most 5 decimal places.
|
||||
///
|
||||
/// Rounding to a fixed precision keeps the output bounded and identical
|
||||
/// across the JS, PHP, Python, Rust, Go and Dart ports: every value becomes
|
||||
/// a multiple of 1e-5, which has no exponential form, so the result is built
|
||||
/// from integer arithmetic with no language-specific float stringifying.
|
||||
/// `double.toString()` must never be used here — Dart renders integral
|
||||
/// doubles as `1.0` where JavaScript prints `1`.
|
||||
String formatNumber(double value) {
|
||||
if (value.isNaN) {
|
||||
return 'NaN';
|
||||
}
|
||||
|
||||
if (value == double.infinity) {
|
||||
return 'Infinity';
|
||||
}
|
||||
|
||||
if (value == double.negativeInfinity) {
|
||||
return '-Infinity';
|
||||
}
|
||||
|
||||
var scaled = roundHalfUp(value * 100000.0).toInt();
|
||||
final sign = scaled < 0 ? '-' : '';
|
||||
|
||||
if (scaled < 0) {
|
||||
scaled = -scaled;
|
||||
}
|
||||
|
||||
final integerPart = scaled ~/ 100000;
|
||||
final fraction = (scaled % 100000)
|
||||
.toString()
|
||||
.padLeft(5, '0')
|
||||
.replaceFirst(_trailingZeros, '');
|
||||
|
||||
if (fraction.isEmpty) {
|
||||
return '$sign$integerPart';
|
||||
}
|
||||
|
||||
return '$sign$integerPart.$fraction';
|
||||
}
|
||||
|
||||
final _trailingZeros = RegExp(r'0+$');
|
||||
|
||||
/// Converts a number to the string JavaScript's `String(value)` produces.
|
||||
///
|
||||
/// Used wherever a number becomes a PRNG dedupe/sort key (e.g. `fontWeight`
|
||||
/// lists), so the key must match the JS port byte for byte: integral values
|
||||
/// print without a decimal point (`400`, not Dart's `400.0`).
|
||||
///
|
||||
/// The zero check comes first on purpose: on the web `-0.0 is int` is true
|
||||
/// and dart2js prints `-0.0` (where JS prints `0`), so the naive integral
|
||||
/// branch would silently diverge on the web only.
|
||||
String jsNumString(num value) {
|
||||
if (value == 0) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
final d = value.toDouble();
|
||||
|
||||
if (d.isNaN) {
|
||||
return 'NaN';
|
||||
}
|
||||
|
||||
if (d == double.infinity) {
|
||||
return 'Infinity';
|
||||
}
|
||||
|
||||
if (d == double.negativeInfinity) {
|
||||
return '-Infinity';
|
||||
}
|
||||
|
||||
if (d == d.truncateToDouble() && d.abs() < 1e21) {
|
||||
// BigInt is exact for every integral double; `toInt()` would clamp
|
||||
// values at or above 2^63 on the VM, and JS prints e.g. String(1e20)
|
||||
// as "100000000000000000000".
|
||||
return BigInt.from(d).toString();
|
||||
}
|
||||
|
||||
return d.toString();
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
/// JavaScript-compatible Unicode uppercasing for the initials feature.
|
||||
///
|
||||
/// `String.toUpperCase()` on the Dart VM applies only simple (1:1) case
|
||||
/// mappings, while the JavaScript reference (and dart2js, which delegates to
|
||||
/// the host) applies full Unicode case mapping — `ß` becomes `SS`, the `fi`
|
||||
/// ligature becomes `FI`, and Greek iota-subscript forms expand to two
|
||||
/// letters. Without the overrides below, the same package would produce
|
||||
/// different initials on the VM than on the web, and both could diverge from
|
||||
/// the other language ports (Go uses `golang.org/x/text` for the same
|
||||
/// reason).
|
||||
library;
|
||||
|
||||
/// Uppercases [value] with full Unicode case mapping, matching
|
||||
/// `String.prototype.toUpperCase` of the JavaScript reference.
|
||||
String jsToUpperCase(String value) {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
for (final rune in value.runes) {
|
||||
final override = _overrides[rune];
|
||||
|
||||
if (override != null) {
|
||||
buffer.write(override);
|
||||
} else {
|
||||
buffer.write(String.fromCharCode(rune).toUpperCase());
|
||||
}
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
// Every code point whose JavaScript full uppercase mapping differs from the
|
||||
// Dart VM's simple mapping. Regenerate when the bundled Dart SDK or the host
|
||||
// JS engine moves to a new Unicode version, by diffing the two mappings over
|
||||
// all of Unicode:
|
||||
//
|
||||
// 1. With Node: for every code point 0x0..0x10FFFF except the surrogate
|
||||
// range 0xD800..0xDFFF, record String.fromCodePoint(cp).toUpperCase().
|
||||
// 2. With the Dart VM: for each, compare String.fromCharCode(cp)
|
||||
// .toUpperCase() against the Node value; keep the entries that differ.
|
||||
// 3. Emit them here as `0xXXXX: '<js value>'`, sorted by code point, with
|
||||
// non-ASCII output written as `\u{...}` escapes.
|
||||
//
|
||||
// The verified invariant: this set equals exactly the code points where the
|
||||
// VM's simple mapping disagrees with the host's full mapping (no missing, no
|
||||
// redundant entries), so on the VM the table supplies the full mapping and on
|
||||
// the web it reproduces the host result the fallback would already give.
|
||||
const Map<int, String> _overrides = {
|
||||
0x00df: 'SS',
|
||||
0x0149: '\u{2bc}N',
|
||||
0x019b: '\u{a7dc}',
|
||||
0x01f0: 'J\u{30c}',
|
||||
0x023f: '\u{2c7e}',
|
||||
0x0240: '\u{2c7f}',
|
||||
0x0252: '\u{2c70}',
|
||||
0x025c: '\u{a7ab}',
|
||||
0x0261: '\u{a7ac}',
|
||||
0x0264: '\u{a7cb}',
|
||||
0x0265: '\u{a78d}',
|
||||
0x0266: '\u{a7aa}',
|
||||
0x026a: '\u{a7ae}',
|
||||
0x026c: '\u{a7ad}',
|
||||
0x0282: '\u{a7c5}',
|
||||
0x0287: '\u{a7b1}',
|
||||
0x029d: '\u{a7b2}',
|
||||
0x029e: '\u{a7b0}',
|
||||
0x0390: '\u{399}\u{308}\u{301}',
|
||||
0x03b0: '\u{3a5}\u{308}\u{301}',
|
||||
0x03f3: '\u{37f}',
|
||||
0x0525: '\u{524}',
|
||||
0x0527: '\u{526}',
|
||||
0x0529: '\u{528}',
|
||||
0x052b: '\u{52a}',
|
||||
0x052d: '\u{52c}',
|
||||
0x052f: '\u{52e}',
|
||||
0x0587: '\u{535}\u{552}',
|
||||
0x10d0: '\u{1c90}',
|
||||
0x10d1: '\u{1c91}',
|
||||
0x10d2: '\u{1c92}',
|
||||
0x10d3: '\u{1c93}',
|
||||
0x10d4: '\u{1c94}',
|
||||
0x10d5: '\u{1c95}',
|
||||
0x10d6: '\u{1c96}',
|
||||
0x10d7: '\u{1c97}',
|
||||
0x10d8: '\u{1c98}',
|
||||
0x10d9: '\u{1c99}',
|
||||
0x10da: '\u{1c9a}',
|
||||
0x10db: '\u{1c9b}',
|
||||
0x10dc: '\u{1c9c}',
|
||||
0x10dd: '\u{1c9d}',
|
||||
0x10de: '\u{1c9e}',
|
||||
0x10df: '\u{1c9f}',
|
||||
0x10e0: '\u{1ca0}',
|
||||
0x10e1: '\u{1ca1}',
|
||||
0x10e2: '\u{1ca2}',
|
||||
0x10e3: '\u{1ca3}',
|
||||
0x10e4: '\u{1ca4}',
|
||||
0x10e5: '\u{1ca5}',
|
||||
0x10e6: '\u{1ca6}',
|
||||
0x10e7: '\u{1ca7}',
|
||||
0x10e8: '\u{1ca8}',
|
||||
0x10e9: '\u{1ca9}',
|
||||
0x10ea: '\u{1caa}',
|
||||
0x10eb: '\u{1cab}',
|
||||
0x10ec: '\u{1cac}',
|
||||
0x10ed: '\u{1cad}',
|
||||
0x10ee: '\u{1cae}',
|
||||
0x10ef: '\u{1caf}',
|
||||
0x10f0: '\u{1cb0}',
|
||||
0x10f1: '\u{1cb1}',
|
||||
0x10f2: '\u{1cb2}',
|
||||
0x10f3: '\u{1cb3}',
|
||||
0x10f4: '\u{1cb4}',
|
||||
0x10f5: '\u{1cb5}',
|
||||
0x10f6: '\u{1cb6}',
|
||||
0x10f7: '\u{1cb7}',
|
||||
0x10f8: '\u{1cb8}',
|
||||
0x10f9: '\u{1cb9}',
|
||||
0x10fa: '\u{1cba}',
|
||||
0x10fd: '\u{1cbd}',
|
||||
0x10fe: '\u{1cbe}',
|
||||
0x10ff: '\u{1cbf}',
|
||||
0x13f8: '\u{13f0}',
|
||||
0x13f9: '\u{13f1}',
|
||||
0x13fa: '\u{13f2}',
|
||||
0x13fb: '\u{13f3}',
|
||||
0x13fc: '\u{13f4}',
|
||||
0x13fd: '\u{13f5}',
|
||||
0x1c80: '\u{412}',
|
||||
0x1c81: '\u{414}',
|
||||
0x1c82: '\u{41e}',
|
||||
0x1c83: '\u{421}',
|
||||
0x1c84: '\u{422}',
|
||||
0x1c85: '\u{422}',
|
||||
0x1c86: '\u{42a}',
|
||||
0x1c87: '\u{462}',
|
||||
0x1c88: '\u{a64a}',
|
||||
0x1c8a: '\u{1c89}',
|
||||
0x1d8e: '\u{a7c6}',
|
||||
0x1e96: 'H\u{331}',
|
||||
0x1e97: 'T\u{308}',
|
||||
0x1e98: 'W\u{30a}',
|
||||
0x1e99: 'Y\u{30a}',
|
||||
0x1e9a: 'A\u{2be}',
|
||||
0x1f50: '\u{3a5}\u{313}',
|
||||
0x1f52: '\u{3a5}\u{313}\u{300}',
|
||||
0x1f54: '\u{3a5}\u{313}\u{301}',
|
||||
0x1f56: '\u{3a5}\u{313}\u{342}',
|
||||
0x1f80: '\u{1f08}\u{399}',
|
||||
0x1f81: '\u{1f09}\u{399}',
|
||||
0x1f82: '\u{1f0a}\u{399}',
|
||||
0x1f83: '\u{1f0b}\u{399}',
|
||||
0x1f84: '\u{1f0c}\u{399}',
|
||||
0x1f85: '\u{1f0d}\u{399}',
|
||||
0x1f86: '\u{1f0e}\u{399}',
|
||||
0x1f87: '\u{1f0f}\u{399}',
|
||||
0x1f88: '\u{1f08}\u{399}',
|
||||
0x1f89: '\u{1f09}\u{399}',
|
||||
0x1f8a: '\u{1f0a}\u{399}',
|
||||
0x1f8b: '\u{1f0b}\u{399}',
|
||||
0x1f8c: '\u{1f0c}\u{399}',
|
||||
0x1f8d: '\u{1f0d}\u{399}',
|
||||
0x1f8e: '\u{1f0e}\u{399}',
|
||||
0x1f8f: '\u{1f0f}\u{399}',
|
||||
0x1f90: '\u{1f28}\u{399}',
|
||||
0x1f91: '\u{1f29}\u{399}',
|
||||
0x1f92: '\u{1f2a}\u{399}',
|
||||
0x1f93: '\u{1f2b}\u{399}',
|
||||
0x1f94: '\u{1f2c}\u{399}',
|
||||
0x1f95: '\u{1f2d}\u{399}',
|
||||
0x1f96: '\u{1f2e}\u{399}',
|
||||
0x1f97: '\u{1f2f}\u{399}',
|
||||
0x1f98: '\u{1f28}\u{399}',
|
||||
0x1f99: '\u{1f29}\u{399}',
|
||||
0x1f9a: '\u{1f2a}\u{399}',
|
||||
0x1f9b: '\u{1f2b}\u{399}',
|
||||
0x1f9c: '\u{1f2c}\u{399}',
|
||||
0x1f9d: '\u{1f2d}\u{399}',
|
||||
0x1f9e: '\u{1f2e}\u{399}',
|
||||
0x1f9f: '\u{1f2f}\u{399}',
|
||||
0x1fa0: '\u{1f68}\u{399}',
|
||||
0x1fa1: '\u{1f69}\u{399}',
|
||||
0x1fa2: '\u{1f6a}\u{399}',
|
||||
0x1fa3: '\u{1f6b}\u{399}',
|
||||
0x1fa4: '\u{1f6c}\u{399}',
|
||||
0x1fa5: '\u{1f6d}\u{399}',
|
||||
0x1fa6: '\u{1f6e}\u{399}',
|
||||
0x1fa7: '\u{1f6f}\u{399}',
|
||||
0x1fa8: '\u{1f68}\u{399}',
|
||||
0x1fa9: '\u{1f69}\u{399}',
|
||||
0x1faa: '\u{1f6a}\u{399}',
|
||||
0x1fab: '\u{1f6b}\u{399}',
|
||||
0x1fac: '\u{1f6c}\u{399}',
|
||||
0x1fad: '\u{1f6d}\u{399}',
|
||||
0x1fae: '\u{1f6e}\u{399}',
|
||||
0x1faf: '\u{1f6f}\u{399}',
|
||||
0x1fb2: '\u{1fba}\u{399}',
|
||||
0x1fb3: '\u{391}\u{399}',
|
||||
0x1fb4: '\u{386}\u{399}',
|
||||
0x1fb6: '\u{391}\u{342}',
|
||||
0x1fb7: '\u{391}\u{342}\u{399}',
|
||||
0x1fbc: '\u{391}\u{399}',
|
||||
0x1fc2: '\u{1fca}\u{399}',
|
||||
0x1fc3: '\u{397}\u{399}',
|
||||
0x1fc4: '\u{389}\u{399}',
|
||||
0x1fc6: '\u{397}\u{342}',
|
||||
0x1fc7: '\u{397}\u{342}\u{399}',
|
||||
0x1fcc: '\u{397}\u{399}',
|
||||
0x1fd2: '\u{399}\u{308}\u{300}',
|
||||
0x1fd3: '\u{399}\u{308}\u{301}',
|
||||
0x1fd6: '\u{399}\u{342}',
|
||||
0x1fd7: '\u{399}\u{308}\u{342}',
|
||||
0x1fe2: '\u{3a5}\u{308}\u{300}',
|
||||
0x1fe3: '\u{3a5}\u{308}\u{301}',
|
||||
0x1fe4: '\u{3a1}\u{313}',
|
||||
0x1fe6: '\u{3a5}\u{342}',
|
||||
0x1fe7: '\u{3a5}\u{308}\u{342}',
|
||||
0x1ff2: '\u{1ffa}\u{399}',
|
||||
0x1ff3: '\u{3a9}\u{399}',
|
||||
0x1ff4: '\u{38f}\u{399}',
|
||||
0x1ff6: '\u{3a9}\u{342}',
|
||||
0x1ff7: '\u{3a9}\u{342}\u{399}',
|
||||
0x1ffc: '\u{3a9}\u{399}',
|
||||
0x2c5f: '\u{2c2f}',
|
||||
0x2cec: '\u{2ceb}',
|
||||
0x2cee: '\u{2ced}',
|
||||
0x2cf3: '\u{2cf2}',
|
||||
0x2d27: '\u{10c7}',
|
||||
0x2d2d: '\u{10cd}',
|
||||
0xa661: '\u{a660}',
|
||||
0xa699: '\u{a698}',
|
||||
0xa69b: '\u{a69a}',
|
||||
0xa791: '\u{a790}',
|
||||
0xa793: '\u{a792}',
|
||||
0xa794: '\u{a7c4}',
|
||||
0xa797: '\u{a796}',
|
||||
0xa799: '\u{a798}',
|
||||
0xa79b: '\u{a79a}',
|
||||
0xa79d: '\u{a79c}',
|
||||
0xa79f: '\u{a79e}',
|
||||
0xa7a1: '\u{a7a0}',
|
||||
0xa7a3: '\u{a7a2}',
|
||||
0xa7a5: '\u{a7a4}',
|
||||
0xa7a7: '\u{a7a6}',
|
||||
0xa7a9: '\u{a7a8}',
|
||||
0xa7b5: '\u{a7b4}',
|
||||
0xa7b7: '\u{a7b6}',
|
||||
0xa7b9: '\u{a7b8}',
|
||||
0xa7bb: '\u{a7ba}',
|
||||
0xa7bd: '\u{a7bc}',
|
||||
0xa7bf: '\u{a7be}',
|
||||
0xa7c1: '\u{a7c0}',
|
||||
0xa7c3: '\u{a7c2}',
|
||||
0xa7c8: '\u{a7c7}',
|
||||
0xa7ca: '\u{a7c9}',
|
||||
0xa7cd: '\u{a7cc}',
|
||||
0xa7d1: '\u{a7d0}',
|
||||
0xa7d7: '\u{a7d6}',
|
||||
0xa7d9: '\u{a7d8}',
|
||||
0xa7db: '\u{a7da}',
|
||||
0xa7f6: '\u{a7f5}',
|
||||
0xab53: '\u{a7b3}',
|
||||
0xab70: '\u{13a0}',
|
||||
0xab71: '\u{13a1}',
|
||||
0xab72: '\u{13a2}',
|
||||
0xab73: '\u{13a3}',
|
||||
0xab74: '\u{13a4}',
|
||||
0xab75: '\u{13a5}',
|
||||
0xab76: '\u{13a6}',
|
||||
0xab77: '\u{13a7}',
|
||||
0xab78: '\u{13a8}',
|
||||
0xab79: '\u{13a9}',
|
||||
0xab7a: '\u{13aa}',
|
||||
0xab7b: '\u{13ab}',
|
||||
0xab7c: '\u{13ac}',
|
||||
0xab7d: '\u{13ad}',
|
||||
0xab7e: '\u{13ae}',
|
||||
0xab7f: '\u{13af}',
|
||||
0xab80: '\u{13b0}',
|
||||
0xab81: '\u{13b1}',
|
||||
0xab82: '\u{13b2}',
|
||||
0xab83: '\u{13b3}',
|
||||
0xab84: '\u{13b4}',
|
||||
0xab85: '\u{13b5}',
|
||||
0xab86: '\u{13b6}',
|
||||
0xab87: '\u{13b7}',
|
||||
0xab88: '\u{13b8}',
|
||||
0xab89: '\u{13b9}',
|
||||
0xab8a: '\u{13ba}',
|
||||
0xab8b: '\u{13bb}',
|
||||
0xab8c: '\u{13bc}',
|
||||
0xab8d: '\u{13bd}',
|
||||
0xab8e: '\u{13be}',
|
||||
0xab8f: '\u{13bf}',
|
||||
0xab90: '\u{13c0}',
|
||||
0xab91: '\u{13c1}',
|
||||
0xab92: '\u{13c2}',
|
||||
0xab93: '\u{13c3}',
|
||||
0xab94: '\u{13c4}',
|
||||
0xab95: '\u{13c5}',
|
||||
0xab96: '\u{13c6}',
|
||||
0xab97: '\u{13c7}',
|
||||
0xab98: '\u{13c8}',
|
||||
0xab99: '\u{13c9}',
|
||||
0xab9a: '\u{13ca}',
|
||||
0xab9b: '\u{13cb}',
|
||||
0xab9c: '\u{13cc}',
|
||||
0xab9d: '\u{13cd}',
|
||||
0xab9e: '\u{13ce}',
|
||||
0xab9f: '\u{13cf}',
|
||||
0xaba0: '\u{13d0}',
|
||||
0xaba1: '\u{13d1}',
|
||||
0xaba2: '\u{13d2}',
|
||||
0xaba3: '\u{13d3}',
|
||||
0xaba4: '\u{13d4}',
|
||||
0xaba5: '\u{13d5}',
|
||||
0xaba6: '\u{13d6}',
|
||||
0xaba7: '\u{13d7}',
|
||||
0xaba8: '\u{13d8}',
|
||||
0xaba9: '\u{13d9}',
|
||||
0xabaa: '\u{13da}',
|
||||
0xabab: '\u{13db}',
|
||||
0xabac: '\u{13dc}',
|
||||
0xabad: '\u{13dd}',
|
||||
0xabae: '\u{13de}',
|
||||
0xabaf: '\u{13df}',
|
||||
0xabb0: '\u{13e0}',
|
||||
0xabb1: '\u{13e1}',
|
||||
0xabb2: '\u{13e2}',
|
||||
0xabb3: '\u{13e3}',
|
||||
0xabb4: '\u{13e4}',
|
||||
0xabb5: '\u{13e5}',
|
||||
0xabb6: '\u{13e6}',
|
||||
0xabb7: '\u{13e7}',
|
||||
0xabb8: '\u{13e8}',
|
||||
0xabb9: '\u{13e9}',
|
||||
0xabba: '\u{13ea}',
|
||||
0xabbb: '\u{13eb}',
|
||||
0xabbc: '\u{13ec}',
|
||||
0xabbd: '\u{13ed}',
|
||||
0xabbe: '\u{13ee}',
|
||||
0xabbf: '\u{13ef}',
|
||||
0xfb00: 'FF',
|
||||
0xfb01: 'FI',
|
||||
0xfb02: 'FL',
|
||||
0xfb03: 'FFI',
|
||||
0xfb04: 'FFL',
|
||||
0xfb05: 'ST',
|
||||
0xfb06: 'ST',
|
||||
0xfb13: '\u{544}\u{546}',
|
||||
0xfb14: '\u{544}\u{535}',
|
||||
0xfb15: '\u{544}\u{53b}',
|
||||
0xfb16: '\u{54e}\u{546}',
|
||||
0xfb17: '\u{544}\u{53d}',
|
||||
0x104d8: '\u{104b0}',
|
||||
0x104d9: '\u{104b1}',
|
||||
0x104da: '\u{104b2}',
|
||||
0x104db: '\u{104b3}',
|
||||
0x104dc: '\u{104b4}',
|
||||
0x104dd: '\u{104b5}',
|
||||
0x104de: '\u{104b6}',
|
||||
0x104df: '\u{104b7}',
|
||||
0x104e0: '\u{104b8}',
|
||||
0x104e1: '\u{104b9}',
|
||||
0x104e2: '\u{104ba}',
|
||||
0x104e3: '\u{104bb}',
|
||||
0x104e4: '\u{104bc}',
|
||||
0x104e5: '\u{104bd}',
|
||||
0x104e6: '\u{104be}',
|
||||
0x104e7: '\u{104bf}',
|
||||
0x104e8: '\u{104c0}',
|
||||
0x104e9: '\u{104c1}',
|
||||
0x104ea: '\u{104c2}',
|
||||
0x104eb: '\u{104c3}',
|
||||
0x104ec: '\u{104c4}',
|
||||
0x104ed: '\u{104c5}',
|
||||
0x104ee: '\u{104c6}',
|
||||
0x104ef: '\u{104c7}',
|
||||
0x104f0: '\u{104c8}',
|
||||
0x104f1: '\u{104c9}',
|
||||
0x104f2: '\u{104ca}',
|
||||
0x104f3: '\u{104cb}',
|
||||
0x104f4: '\u{104cc}',
|
||||
0x104f5: '\u{104cd}',
|
||||
0x104f6: '\u{104ce}',
|
||||
0x104f7: '\u{104cf}',
|
||||
0x104f8: '\u{104d0}',
|
||||
0x104f9: '\u{104d1}',
|
||||
0x104fa: '\u{104d2}',
|
||||
0x104fb: '\u{104d3}',
|
||||
0x10597: '\u{10570}',
|
||||
0x10598: '\u{10571}',
|
||||
0x10599: '\u{10572}',
|
||||
0x1059a: '\u{10573}',
|
||||
0x1059b: '\u{10574}',
|
||||
0x1059c: '\u{10575}',
|
||||
0x1059d: '\u{10576}',
|
||||
0x1059e: '\u{10577}',
|
||||
0x1059f: '\u{10578}',
|
||||
0x105a0: '\u{10579}',
|
||||
0x105a1: '\u{1057a}',
|
||||
0x105a3: '\u{1057c}',
|
||||
0x105a4: '\u{1057d}',
|
||||
0x105a5: '\u{1057e}',
|
||||
0x105a6: '\u{1057f}',
|
||||
0x105a7: '\u{10580}',
|
||||
0x105a8: '\u{10581}',
|
||||
0x105a9: '\u{10582}',
|
||||
0x105aa: '\u{10583}',
|
||||
0x105ab: '\u{10584}',
|
||||
0x105ac: '\u{10585}',
|
||||
0x105ad: '\u{10586}',
|
||||
0x105ae: '\u{10587}',
|
||||
0x105af: '\u{10588}',
|
||||
0x105b0: '\u{10589}',
|
||||
0x105b1: '\u{1058a}',
|
||||
0x105b3: '\u{1058c}',
|
||||
0x105b4: '\u{1058d}',
|
||||
0x105b5: '\u{1058e}',
|
||||
0x105b6: '\u{1058f}',
|
||||
0x105b7: '\u{10590}',
|
||||
0x105b8: '\u{10591}',
|
||||
0x105b9: '\u{10592}',
|
||||
0x105bb: '\u{10594}',
|
||||
0x105bc: '\u{10595}',
|
||||
0x10cc0: '\u{10c80}',
|
||||
0x10cc1: '\u{10c81}',
|
||||
0x10cc2: '\u{10c82}',
|
||||
0x10cc3: '\u{10c83}',
|
||||
0x10cc4: '\u{10c84}',
|
||||
0x10cc5: '\u{10c85}',
|
||||
0x10cc6: '\u{10c86}',
|
||||
0x10cc7: '\u{10c87}',
|
||||
0x10cc8: '\u{10c88}',
|
||||
0x10cc9: '\u{10c89}',
|
||||
0x10cca: '\u{10c8a}',
|
||||
0x10ccb: '\u{10c8b}',
|
||||
0x10ccc: '\u{10c8c}',
|
||||
0x10ccd: '\u{10c8d}',
|
||||
0x10cce: '\u{10c8e}',
|
||||
0x10ccf: '\u{10c8f}',
|
||||
0x10cd0: '\u{10c90}',
|
||||
0x10cd1: '\u{10c91}',
|
||||
0x10cd2: '\u{10c92}',
|
||||
0x10cd3: '\u{10c93}',
|
||||
0x10cd4: '\u{10c94}',
|
||||
0x10cd5: '\u{10c95}',
|
||||
0x10cd6: '\u{10c96}',
|
||||
0x10cd7: '\u{10c97}',
|
||||
0x10cd8: '\u{10c98}',
|
||||
0x10cd9: '\u{10c99}',
|
||||
0x10cda: '\u{10c9a}',
|
||||
0x10cdb: '\u{10c9b}',
|
||||
0x10cdc: '\u{10c9c}',
|
||||
0x10cdd: '\u{10c9d}',
|
||||
0x10cde: '\u{10c9e}',
|
||||
0x10cdf: '\u{10c9f}',
|
||||
0x10ce0: '\u{10ca0}',
|
||||
0x10ce1: '\u{10ca1}',
|
||||
0x10ce2: '\u{10ca2}',
|
||||
0x10ce3: '\u{10ca3}',
|
||||
0x10ce4: '\u{10ca4}',
|
||||
0x10ce5: '\u{10ca5}',
|
||||
0x10ce6: '\u{10ca6}',
|
||||
0x10ce7: '\u{10ca7}',
|
||||
0x10ce8: '\u{10ca8}',
|
||||
0x10ce9: '\u{10ca9}',
|
||||
0x10cea: '\u{10caa}',
|
||||
0x10ceb: '\u{10cab}',
|
||||
0x10cec: '\u{10cac}',
|
||||
0x10ced: '\u{10cad}',
|
||||
0x10cee: '\u{10cae}',
|
||||
0x10cef: '\u{10caf}',
|
||||
0x10cf0: '\u{10cb0}',
|
||||
0x10cf1: '\u{10cb1}',
|
||||
0x10cf2: '\u{10cb2}',
|
||||
0x10d70: '\u{10d50}',
|
||||
0x10d71: '\u{10d51}',
|
||||
0x10d72: '\u{10d52}',
|
||||
0x10d73: '\u{10d53}',
|
||||
0x10d74: '\u{10d54}',
|
||||
0x10d75: '\u{10d55}',
|
||||
0x10d76: '\u{10d56}',
|
||||
0x10d77: '\u{10d57}',
|
||||
0x10d78: '\u{10d58}',
|
||||
0x10d79: '\u{10d59}',
|
||||
0x10d7a: '\u{10d5a}',
|
||||
0x10d7b: '\u{10d5b}',
|
||||
0x10d7c: '\u{10d5c}',
|
||||
0x10d7d: '\u{10d5d}',
|
||||
0x10d7e: '\u{10d5e}',
|
||||
0x10d7f: '\u{10d5f}',
|
||||
0x10d80: '\u{10d60}',
|
||||
0x10d81: '\u{10d61}',
|
||||
0x10d82: '\u{10d62}',
|
||||
0x10d83: '\u{10d63}',
|
||||
0x10d84: '\u{10d64}',
|
||||
0x10d85: '\u{10d65}',
|
||||
0x118c0: '\u{118a0}',
|
||||
0x118c1: '\u{118a1}',
|
||||
0x118c2: '\u{118a2}',
|
||||
0x118c3: '\u{118a3}',
|
||||
0x118c4: '\u{118a4}',
|
||||
0x118c5: '\u{118a5}',
|
||||
0x118c6: '\u{118a6}',
|
||||
0x118c7: '\u{118a7}',
|
||||
0x118c8: '\u{118a8}',
|
||||
0x118c9: '\u{118a9}',
|
||||
0x118ca: '\u{118aa}',
|
||||
0x118cb: '\u{118ab}',
|
||||
0x118cc: '\u{118ac}',
|
||||
0x118cd: '\u{118ad}',
|
||||
0x118ce: '\u{118ae}',
|
||||
0x118cf: '\u{118af}',
|
||||
0x118d0: '\u{118b0}',
|
||||
0x118d1: '\u{118b1}',
|
||||
0x118d2: '\u{118b2}',
|
||||
0x118d3: '\u{118b3}',
|
||||
0x118d4: '\u{118b4}',
|
||||
0x118d5: '\u{118b5}',
|
||||
0x118d6: '\u{118b6}',
|
||||
0x118d7: '\u{118b7}',
|
||||
0x118d8: '\u{118b8}',
|
||||
0x118d9: '\u{118b9}',
|
||||
0x118da: '\u{118ba}',
|
||||
0x118db: '\u{118bb}',
|
||||
0x118dc: '\u{118bc}',
|
||||
0x118dd: '\u{118bd}',
|
||||
0x118de: '\u{118be}',
|
||||
0x118df: '\u{118bf}',
|
||||
0x16e60: '\u{16e40}',
|
||||
0x16e61: '\u{16e41}',
|
||||
0x16e62: '\u{16e42}',
|
||||
0x16e63: '\u{16e43}',
|
||||
0x16e64: '\u{16e44}',
|
||||
0x16e65: '\u{16e45}',
|
||||
0x16e66: '\u{16e46}',
|
||||
0x16e67: '\u{16e47}',
|
||||
0x16e68: '\u{16e48}',
|
||||
0x16e69: '\u{16e49}',
|
||||
0x16e6a: '\u{16e4a}',
|
||||
0x16e6b: '\u{16e4b}',
|
||||
0x16e6c: '\u{16e4c}',
|
||||
0x16e6d: '\u{16e4d}',
|
||||
0x16e6e: '\u{16e4e}',
|
||||
0x16e6f: '\u{16e4f}',
|
||||
0x16e70: '\u{16e50}',
|
||||
0x16e71: '\u{16e51}',
|
||||
0x16e72: '\u{16e52}',
|
||||
0x16e73: '\u{16e53}',
|
||||
0x16e74: '\u{16e54}',
|
||||
0x16e75: '\u{16e55}',
|
||||
0x16e76: '\u{16e56}',
|
||||
0x16e77: '\u{16e57}',
|
||||
0x16e78: '\u{16e58}',
|
||||
0x16e79: '\u{16e59}',
|
||||
0x16e7a: '\u{16e5a}',
|
||||
0x16e7b: '\u{16e5b}',
|
||||
0x16e7c: '\u{16e5c}',
|
||||
0x16e7d: '\u{16e5d}',
|
||||
0x16e7e: '\u{16e5e}',
|
||||
0x16e7f: '\u{16e5f}',
|
||||
0x1e922: '\u{1e900}',
|
||||
0x1e923: '\u{1e901}',
|
||||
0x1e924: '\u{1e902}',
|
||||
0x1e925: '\u{1e903}',
|
||||
0x1e926: '\u{1e904}',
|
||||
0x1e927: '\u{1e905}',
|
||||
0x1e928: '\u{1e906}',
|
||||
0x1e929: '\u{1e907}',
|
||||
0x1e92a: '\u{1e908}',
|
||||
0x1e92b: '\u{1e909}',
|
||||
0x1e92c: '\u{1e90a}',
|
||||
0x1e92d: '\u{1e90b}',
|
||||
0x1e92e: '\u{1e90c}',
|
||||
0x1e92f: '\u{1e90d}',
|
||||
0x1e930: '\u{1e90e}',
|
||||
0x1e931: '\u{1e90f}',
|
||||
0x1e932: '\u{1e910}',
|
||||
0x1e933: '\u{1e911}',
|
||||
0x1e934: '\u{1e912}',
|
||||
0x1e935: '\u{1e913}',
|
||||
0x1e936: '\u{1e914}',
|
||||
0x1e937: '\u{1e915}',
|
||||
0x1e938: '\u{1e916}',
|
||||
0x1e939: '\u{1e917}',
|
||||
0x1e93a: '\u{1e918}',
|
||||
0x1e93b: '\u{1e919}',
|
||||
0x1e93c: '\u{1e91a}',
|
||||
0x1e93d: '\u{1e91b}',
|
||||
0x1e93e: '\u{1e91c}',
|
||||
0x1e93f: '\u{1e91d}',
|
||||
0x1e940: '\u{1e91e}',
|
||||
0x1e941: '\u{1e91f}',
|
||||
0x1e942: '\u{1e920}',
|
||||
0x1e943: '\u{1e921}',
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/// Minimal XML escaping helper for SVG/XML text and attribute content.
|
||||
library;
|
||||
|
||||
const _entities = <String, String>{
|
||||
'&': '&',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
};
|
||||
|
||||
final _pattern = RegExp('[&\'"<>]');
|
||||
|
||||
/// Returns [value] with the five XML predefined entities escaped, in a
|
||||
/// single pass like the JS port's regex replace.
|
||||
String escapeXml(String value) {
|
||||
return value.replaceAllMapped(_pattern, (match) => _entities[match[0]]!);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/// Runtime validation of avatar options against the shared draft-07 JSON
|
||||
/// Schema, which ships as the pure-data `dicebear_schema` package.
|
||||
///
|
||||
/// The compiled validator matches the Ajv/opis/jsonschema-rs/santhosh-tekuri
|
||||
/// validators used by the other language ports: every port must accept and
|
||||
/// reject the same inputs (pinned by the `validation.json` parity fixture);
|
||||
/// only the error message text is language-specific.
|
||||
library;
|
||||
|
||||
import 'package:dicebear_schema/dicebear_schema.dart' as dicebear_schema;
|
||||
import 'package:json_schema/json_schema.dart' as json_schema;
|
||||
|
||||
import '../error/options_validation_error.dart';
|
||||
import 'validator.dart';
|
||||
|
||||
// A top-level `final` variable is initialized lazily on first read, so the
|
||||
// schema is parsed and compiled exactly once and reused afterwards.
|
||||
final json_schema.JsonSchema _optionsSchema = compileSchema(
|
||||
'options.min.json',
|
||||
dicebear_schema.options,
|
||||
);
|
||||
|
||||
/// Throws an [OptionsValidationError] when [data] violates the options
|
||||
/// schema.
|
||||
void validateOptions(Object? data) {
|
||||
final details = collectErrors(_optionsSchema, data);
|
||||
|
||||
if (details.isNotEmpty) {
|
||||
throw OptionsValidationError(details);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/// Runtime validation of style definitions against the shared draft-07 JSON
|
||||
/// Schema, which ships as the pure-data `dicebear_schema` package.
|
||||
///
|
||||
/// The compiled validator matches the Ajv/opis/jsonschema-rs/santhosh-tekuri
|
||||
/// validators used by the other language ports: every port must accept and
|
||||
/// reject the same inputs (pinned by the `validation.json` parity fixture);
|
||||
/// only the error message text is language-specific.
|
||||
library;
|
||||
|
||||
import 'package:dicebear_schema/dicebear_schema.dart' as dicebear_schema;
|
||||
import 'package:json_schema/json_schema.dart' as json_schema;
|
||||
|
||||
import '../error/style_validation_error.dart';
|
||||
import 'validator.dart';
|
||||
|
||||
// A top-level `final` variable is initialized lazily on first read, so the
|
||||
// schema is parsed and compiled exactly once and reused afterwards.
|
||||
final json_schema.JsonSchema _definitionSchema = compileSchema(
|
||||
'definition.min.json',
|
||||
dicebear_schema.definition,
|
||||
);
|
||||
|
||||
/// Throws a [StyleValidationError] when [data] violates the style definition
|
||||
/// schema.
|
||||
void validateStyle(Object? data) {
|
||||
final details = collectErrors(_definitionSchema, data);
|
||||
|
||||
if (details.isNotEmpty) {
|
||||
throw StyleValidationError(details);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/// Shared plumbing for the style and options validators: schema compilation
|
||||
/// and the mapping from `json_schema` failures to [ValidationErrorDetail].
|
||||
///
|
||||
/// The JS port has no counterpart for this file — its validators are
|
||||
/// standalone code generated by Ajv at build time. Here the two validators
|
||||
/// compile their schema at runtime instead and share this helper.
|
||||
library;
|
||||
|
||||
import 'package:json_schema/json_schema.dart' as json_schema;
|
||||
|
||||
import '../error/validation_error.dart';
|
||||
|
||||
/// The schemas are registered under their published CDN URIs, mirroring the
|
||||
/// Go port: a relative or filesystem-derived base URI would leak the
|
||||
/// consumer's environment into validation error output. Keep the version in
|
||||
/// sync with the `dicebear_schema` dependency in `pubspec.yaml`.
|
||||
const String schemaBaseUrl =
|
||||
'https://cdn.hopjs.net/npm/@dicebear/schema@1.2.0/dist/';
|
||||
|
||||
/// Compiles a draft-07 schema from its raw JSON [raw], registered under
|
||||
/// [schemaBaseUrl] + [name].
|
||||
json_schema.JsonSchema compileSchema(String name, String raw) {
|
||||
// The embedded schemas only use internal `#/definitions/...` references, so
|
||||
// the synchronous, fetch-free `create` is sufficient.
|
||||
return json_schema.JsonSchema.create(
|
||||
raw,
|
||||
schemaVersion: json_schema.SchemaVersion.draft7,
|
||||
fetchedFromUri: Uri.parse('$schemaBaseUrl$name'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Validates [data] against [schema] and maps every failure to a
|
||||
/// [ValidationErrorDetail]. An empty result means the data is valid.
|
||||
///
|
||||
/// Unlike Ajv (which stops at the first violation), every failure is
|
||||
/// reported; only accept/reject decisions are parity-pinned, the detail
|
||||
/// list and message text are not.
|
||||
List<ValidationErrorDetail> collectErrors(
|
||||
json_schema.JsonSchema schema,
|
||||
Object? data,
|
||||
) {
|
||||
// JSON cannot represent non-finite numbers, and the Ajv-compiled
|
||||
// validators of the JS reference reject them wherever a number is allowed
|
||||
// (`type: "number"` compiles to `typeof data == "number" && isFinite(data)`).
|
||||
// `package:json_schema` only checks `is num`, and its minimum/maximum
|
||||
// comparisons are always false for NaN — without this walk, Dart would be
|
||||
// the only port to accept NaN/Infinity (Go validates marshalled JSON bytes
|
||||
// and Rust's JSON numbers cannot hold them) and would render broken SVG
|
||||
// like `viewBox="0 0 NaN 100"`.
|
||||
final nonFinite = <ValidationErrorDetail>[];
|
||||
_collectNonFinite(data, '', nonFinite);
|
||||
|
||||
if (nonFinite.isNotEmpty) {
|
||||
return nonFinite;
|
||||
}
|
||||
|
||||
final results = schema.validate(data);
|
||||
|
||||
return [
|
||||
for (final error in results.errors)
|
||||
ValidationErrorDetail(
|
||||
instancePath: error.instancePath,
|
||||
message: error.message,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void _collectNonFinite(
|
||||
Object? node,
|
||||
String path,
|
||||
List<ValidationErrorDetail> out,
|
||||
) {
|
||||
if (node is double && !node.isFinite) {
|
||||
out.add(
|
||||
ValidationErrorDetail(
|
||||
instancePath: path,
|
||||
message: 'must be a finite number',
|
||||
),
|
||||
);
|
||||
} else if (node is List) {
|
||||
for (var i = 0; i < node.length; i++) {
|
||||
_collectNonFinite(node[i], '$path/$i', out);
|
||||
}
|
||||
} else if (node is Map) {
|
||||
for (final entry in node.entries) {
|
||||
_collectNonFinite(
|
||||
entry.value, '$path/${_pointerSegment(entry.key)}', out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Escapes a JSON pointer segment per RFC 6901 (`~` → `~0`, `/` → `~1`).
|
||||
String _pointerSegment(Object? key) {
|
||||
return key.toString().replaceAll('~', '~0').replaceAll('/', '~1');
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
name: dicebear_core
|
||||
description: >-
|
||||
Unique avatars from dozens of styles — deterministic, customizable,
|
||||
vector-based.
|
||||
version: 10.2.0
|
||||
repository: https://github.com/dicebear/dicebear
|
||||
homepage: https://www.dicebear.com
|
||||
topics:
|
||||
- avatar
|
||||
- identicon
|
||||
- svg
|
||||
- dicebear
|
||||
|
||||
environment:
|
||||
sdk: ^3.4.0
|
||||
|
||||
dependencies:
|
||||
# The shared draft-07 JSON Schemas (style definition format and avatar
|
||||
# options), shipped as a pure-data package.
|
||||
dicebear_schema: ^1.2.0
|
||||
# Runtime validation of style definitions and options against those schemas,
|
||||
# matching the Ajv/opis/jsonschema-rs/santhosh-tekuri validators used by the
|
||||
# other language ports.
|
||||
json_schema: ^5.2.2
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^5.0.0
|
||||
test: ^1.25.0
|
||||
@@ -0,0 +1,422 @@
|
||||
// Public-API behavior tests, ported from the Go api_test.go and Rust api.rs
|
||||
// suites (minus the Go/Rust-specific JSON-escaping and schema-URI tests, plus
|
||||
// the renderer/avatar behaviors the Python suite pins). These run without the
|
||||
// parity fixtures, so they also cover the split pub.dev repository.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dicebear_core/dicebear_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
Map<String, Object?> _decode(String json) =>
|
||||
jsonDecode(json) as Map<String, Object?>;
|
||||
|
||||
final Map<String, Object?> _minimalStyle =
|
||||
_decode('{"canvas":{"width":100,"height":100,"elements":[]}}');
|
||||
|
||||
// A style whose canvas declares its own defs entry plus a reference to it —
|
||||
// the Python suite's idRandomization style.
|
||||
final Map<String, Object?> _styleWithIds = _decode('''
|
||||
{
|
||||
"canvas": {
|
||||
"width": 100,
|
||||
"height": 100,
|
||||
"elements": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "defs",
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "linearGradient",
|
||||
"attributes": { "id": "grad1" },
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "stop",
|
||||
"attributes": { "offset": "0%", "stop-color": "red" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "type": "element", "name": "rect", "attributes": { "fill": "url(#grad1)" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
''');
|
||||
|
||||
void main() {
|
||||
group('Style.parse', () {
|
||||
// The raw JSON string is what the dicebear_styles package ships, so
|
||||
// Style.parse(constant) is the primary consumer entry point.
|
||||
final json = jsonEncode(_minimalStyle);
|
||||
|
||||
test('parses a raw JSON definition equivalently to the map constructor',
|
||||
() {
|
||||
final fromString = Avatar(Style.parse(json), {'seed': 'x'});
|
||||
final fromMap = Avatar(Style(_minimalStyle), {'seed': 'x'});
|
||||
|
||||
expect(fromString.svg, fromMap.svg);
|
||||
});
|
||||
|
||||
test('throws FormatException on malformed JSON', () {
|
||||
// Mirrors int.parse / jsonDecode: a parse failure is a FormatException,
|
||||
// distinct from a schema failure.
|
||||
expect(() => Style.parse('not json'), throwsFormatException);
|
||||
});
|
||||
|
||||
test('throws StyleValidationError on a schema-invalid definition', () {
|
||||
expect(() => Style.parse('{}'), throwsA(isA<StyleValidationError>()));
|
||||
});
|
||||
|
||||
test('throws StyleValidationError when the JSON is not an object', () {
|
||||
// Valid JSON, wrong shape: fails as a validation error, not a cast error.
|
||||
expect(() => Style.parse('[]'), throwsA(isA<StyleValidationError>()));
|
||||
});
|
||||
});
|
||||
|
||||
test('toJson exposes svg and resolved options', () {
|
||||
final avatar = Avatar(Style(_minimalStyle), {'seed': 'x'});
|
||||
final json = avatar.toJson();
|
||||
final options = json['options'] as Map<String, Object?>;
|
||||
|
||||
expect(json['svg'], avatar.svg);
|
||||
// The resolved options carry the picked values but never the raw seed.
|
||||
expect(options['flip'], 'none');
|
||||
expect(options.containsKey('seed'), isFalse);
|
||||
});
|
||||
|
||||
test('options descriptor describes components and colors', () {
|
||||
final style = Style(_decode('''
|
||||
{
|
||||
"canvas": { "width": 100, "height": 100, "elements": [] },
|
||||
"components": {
|
||||
"shape": { "width": 100, "height": 100, "variants": { "a": { "elements": [] }, "b": { "elements": [] } } }
|
||||
},
|
||||
"colors": { "fill": { "values": ["#000000"] } }
|
||||
}
|
||||
'''));
|
||||
|
||||
final descriptor = OptionsDescriptor(style).toJson();
|
||||
|
||||
// Fixed top-level fields.
|
||||
expect(descriptor['seed'], {'type': 'string'});
|
||||
|
||||
// Per-component fields (variants sorted).
|
||||
final shapeVariant = descriptor['shapeVariant'] as Map<String, Object?>;
|
||||
final shapeProbability =
|
||||
descriptor['shapeProbability'] as Map<String, Object?>;
|
||||
|
||||
expect(shapeVariant['values'], ['a', 'b']);
|
||||
expect(shapeProbability['type'], 'number');
|
||||
|
||||
// Per-color fields, plus the implicit `background` color.
|
||||
final fillColor = descriptor['fillColor'] as Map<String, Object?>;
|
||||
final backgroundColor =
|
||||
descriptor['backgroundColor'] as Map<String, Object?>;
|
||||
|
||||
expect(fillColor['type'], 'color');
|
||||
expect(backgroundColor['type'], 'color');
|
||||
});
|
||||
|
||||
test('circular color reference is reported', () {
|
||||
final style = Style(_decode('''
|
||||
{
|
||||
"canvas": {
|
||||
"width": 100, "height": 100,
|
||||
"elements": [{ "type": "element", "name": "rect", "attributes": { "fill": { "type": "color", "name": "a" } } }]
|
||||
},
|
||||
"colors": {
|
||||
"a": { "values": ["#000000"], "contrastTo": "b" },
|
||||
"b": { "values": ["#ffffff"], "contrastTo": "a" }
|
||||
}
|
||||
}
|
||||
'''));
|
||||
|
||||
expect(
|
||||
() => Avatar(style, {'seed': 'x'}),
|
||||
throwsA(
|
||||
isA<CircularColorReferenceError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('Circular color reference'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('toJson serializes whole-number options as integers', () {
|
||||
// The other ports emit integers ("size":128), not floats (128.0); the
|
||||
// resolved-options snapshot must match byte-for-byte.
|
||||
final avatar = Avatar(Style(_minimalStyle), {'seed': 'x', 'size': 128});
|
||||
final encoded = jsonEncode(avatar.toJson());
|
||||
|
||||
expect(encoded, contains('"size":128'));
|
||||
expect(encoded, isNot(contains('"size":128.0')));
|
||||
});
|
||||
|
||||
test('toJson emits options in resolution order', () {
|
||||
// The envelope must match the JS port byte-for-byte, including the key
|
||||
// order (size before title — both resolved before the root attributes).
|
||||
// The expected string is the verbatim output of the JS core for the same
|
||||
// style and options.
|
||||
final avatar = Avatar(
|
||||
Style(_minimalStyle),
|
||||
{'seed': 'x', 'size': 128, 'title': 't'},
|
||||
);
|
||||
|
||||
expect(
|
||||
jsonEncode(avatar.toJson()),
|
||||
contains('"options":{"backgroundColorFill":"solid","backgroundColor":[],'
|
||||
'"scale":1,"flip":"none","rotate":0,"translateX":0,"translateY":0,'
|
||||
'"borderRadius":0,"size":128,"title":"t","idRandomization":false}'),
|
||||
);
|
||||
});
|
||||
|
||||
test('toJson does not HTML-escape the embedded SVG', () {
|
||||
// The JS/PHP/Rust ports emit the literal "<svg ...>" in the JSON
|
||||
// envelope; Dart's jsonEncode never HTML-escapes, but pin it anyway.
|
||||
final avatar = Avatar(Style(_minimalStyle), {'seed': 'x'});
|
||||
|
||||
expect(jsonEncode(avatar.toJson()), contains('"svg":"<svg '));
|
||||
});
|
||||
|
||||
test('deeply nested colors resolve without exponential blowup', () {
|
||||
// Each color references the next via BOTH contrastTo and notEqualTo,
|
||||
// which without memoization fans out to 2^depth color resolutions — a
|
||||
// schema-valid hang. With the resolver's memo it is linear; a regression
|
||||
// would make this test never finish.
|
||||
const depth = 40;
|
||||
|
||||
final colors = StringBuffer();
|
||||
|
||||
for (var i = 0; i < depth; i++) {
|
||||
colors.write('"c$i":{"values":["#000000"],'
|
||||
'"contrastTo":"c${i + 1}","notEqualTo":["c${i + 1}"]},');
|
||||
}
|
||||
|
||||
colors.write('"c$depth":{"values":["#ffffff"]}');
|
||||
|
||||
final style = Style(_decode(
|
||||
'{"canvas":{"width":100,"height":100,"elements":'
|
||||
'[{"type":"element","name":"rect","attributes":'
|
||||
'{"fill":{"type":"color","name":"c0"}}}]},"colors":{$colors}}',
|
||||
));
|
||||
|
||||
expect(() => Avatar(style, {'seed': 'x'}), returnsNormally);
|
||||
});
|
||||
|
||||
test('validation accepts and rejects like the other ports', () {
|
||||
final style = Style(_minimalStyle);
|
||||
|
||||
// Accepts a minimal valid style and options.
|
||||
expect(() => Avatar(style, {'seed': 'x'}), returnsNormally);
|
||||
// Null options are treated as empty and accepted.
|
||||
expect(() => Avatar(style), returnsNormally);
|
||||
expect(() => Avatar(style, null), returnsNormally);
|
||||
|
||||
// Rejects a definition missing canvas.
|
||||
expect(
|
||||
() => Style(_decode('{"components":{}}')),
|
||||
throwsA(isA<StyleValidationError>()),
|
||||
);
|
||||
|
||||
// Rejects an alias to an unknown component.
|
||||
expect(
|
||||
() => Style(_decode('{"canvas":{"width":100,"height":100,"elements":[]},'
|
||||
'"components":{"a":{"extends":"missing"}}}')),
|
||||
throwsA(
|
||||
isA<StyleValidationError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('unknown component'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Rejects options with a wrong type (seed must be a string).
|
||||
expect(
|
||||
() => Avatar(style, {'seed': 123}),
|
||||
throwsA(isA<OptionsValidationError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('Uri.encodeComponent matches JS encodeURIComponent', () {
|
||||
// Expected values are exactly what JavaScript's encodeURIComponent
|
||||
// returns; toDataUri builds on this contract.
|
||||
const cases = {
|
||||
'<svg>': '%3Csvg%3E',
|
||||
'a b&c': 'a%20b%26c',
|
||||
"-_.!~*'()": "-_.!~*'()", // the unreserved set passes through
|
||||
'é': '%C3%A9', // multi-byte UTF-8 → per-byte escaping
|
||||
'"#/': '%22%23%2F',
|
||||
};
|
||||
|
||||
cases.forEach((input, want) {
|
||||
expect(Uri.encodeComponent(input), want, reason: input);
|
||||
});
|
||||
});
|
||||
|
||||
test('toDataUri encodes the SVG', () {
|
||||
final avatar = Avatar(Style(_minimalStyle), {'seed': 'x'});
|
||||
final uri = avatar.toDataUri();
|
||||
|
||||
expect(
|
||||
uri,
|
||||
'data:image/svg+xml;charset=utf-8,${Uri.encodeComponent(avatar.svg)}',
|
||||
);
|
||||
// The SVG starts with "<svg", which encodes to "%3Csvg".
|
||||
expect(uri, startsWith('data:image/svg+xml;charset=utf-8,%3Csvg'));
|
||||
});
|
||||
|
||||
group('idRandomization', () {
|
||||
test('randomizes ids when enabled', () {
|
||||
final avatar = Avatar(
|
||||
Style(_styleWithIds),
|
||||
{'seed': 'test', 'idRandomization': true},
|
||||
);
|
||||
|
||||
expect(avatar.svg, isNot(contains('id="grad1"')));
|
||||
expect(avatar.svg, isNot(contains('url(#grad1)')));
|
||||
expect(avatar.svg, contains('id="grad1-'));
|
||||
expect(avatar.svg, contains('url(#grad1-'));
|
||||
});
|
||||
|
||||
test('preserves ids when disabled', () {
|
||||
final avatar = Avatar(
|
||||
Style(_styleWithIds),
|
||||
{'seed': 'test', 'idRandomization': false},
|
||||
);
|
||||
|
||||
expect(avatar.svg, contains('id="grad1"'));
|
||||
expect(avatar.svg, contains('url(#grad1)'));
|
||||
});
|
||||
|
||||
test('uses process randomness, not the seeded PRNG', () {
|
||||
// The same seed must yield different suffixes per render — only the
|
||||
// ids change, never the document structure.
|
||||
String render() => Avatar(
|
||||
Style(_styleWithIds),
|
||||
{'seed': 'test', 'idRandomization': true},
|
||||
).svg;
|
||||
final first = render();
|
||||
final second = render();
|
||||
|
||||
expect(first, isNot(second));
|
||||
|
||||
// Strip each render's single 6-hex suffix; the remaining documents
|
||||
// must be identical.
|
||||
String stripSuffix(String svg) {
|
||||
final suffix = RegExp('grad1-([0-9a-f]{6})').firstMatch(svg)![1]!;
|
||||
|
||||
return svg.replaceAll('-$suffix', '');
|
||||
}
|
||||
|
||||
expect(stripSuffix(first), stripSuffix(second));
|
||||
});
|
||||
});
|
||||
|
||||
group('title', () {
|
||||
test('sets role="img" and an escaped aria-label', () {
|
||||
final avatar = Avatar(
|
||||
Style(_minimalStyle),
|
||||
{'seed': 'x', 'title': 'A & B <C>'},
|
||||
);
|
||||
|
||||
expect(avatar.svg, contains('role="img"'));
|
||||
expect(avatar.svg, contains('aria-label="A & B <C>"'));
|
||||
expect(avatar.svg, contains('<title>A & B <C></title>'));
|
||||
});
|
||||
|
||||
test('falls back to aria-hidden without a title', () {
|
||||
final avatar = Avatar(Style(_minimalStyle), {'seed': 'x'});
|
||||
|
||||
expect(avatar.svg, contains('aria-hidden="true"'));
|
||||
expect(avatar.svg, isNot(contains('role="img"')));
|
||||
expect(avatar.svg, isNot(contains('<title>')));
|
||||
});
|
||||
});
|
||||
|
||||
test('resolved options are exposed as isolated deep copies', () {
|
||||
final avatar = Avatar(
|
||||
Style(_minimalStyle),
|
||||
{'seed': 'x', 'backgroundColor': 'ff0000'},
|
||||
);
|
||||
|
||||
// Mutating a returned envelope must not leak into later calls.
|
||||
final first = avatar.toJson();
|
||||
|
||||
(first['options'] as Map<String, Object?>)['injected'] = 'modified';
|
||||
((first['options'] as Map<String, Object?>)['backgroundColor'] as List)
|
||||
.add('#injected');
|
||||
|
||||
final second = avatar.toJson()['options'] as Map<String, Object?>;
|
||||
|
||||
expect(second.containsKey('injected'), isFalse);
|
||||
expect(second['backgroundColor'], ['#ff0000']);
|
||||
|
||||
// The resolvedOptions getter returns the same normalized shape, also as
|
||||
// a fresh copy per call.
|
||||
final resolved = avatar.resolvedOptions;
|
||||
|
||||
expect(jsonEncode(resolved), jsonEncode(second));
|
||||
|
||||
(resolved['backgroundColor'] as List).clear();
|
||||
|
||||
expect(avatar.resolvedOptions['backgroundColor'], ['#ff0000']);
|
||||
});
|
||||
|
||||
// The Ajv-compiled validators of the JS reference reject NaN/Infinity
|
||||
// wherever a number is allowed; the other ports cannot even represent them
|
||||
// in their JSON values. Without the finiteness walk in the validators, a
|
||||
// NaN canvas width would render `viewBox="0 0 NaN 100"`.
|
||||
group('non-finite numbers', () {
|
||||
test('are rejected in options like the JS reference', () {
|
||||
final style = Style(_minimalStyle);
|
||||
|
||||
expect(
|
||||
() => Avatar(style, {'seed': 'x', 'rotate': double.nan}),
|
||||
throwsA(isA<OptionsValidationError>()),
|
||||
);
|
||||
expect(
|
||||
() => Avatar(style, {'seed': 'x', 'fooProbability': double.nan}),
|
||||
throwsA(isA<OptionsValidationError>()),
|
||||
);
|
||||
// Variant weights have a minimum but no maximum, so Infinity slips
|
||||
// past the range checks and only the finiteness walk catches it.
|
||||
expect(
|
||||
() => Avatar(style, {
|
||||
'seed': 'x',
|
||||
'fooVariant': {'a': double.infinity},
|
||||
}),
|
||||
throwsA(isA<OptionsValidationError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('are rejected in style definitions like the JS reference', () {
|
||||
expect(
|
||||
() => Style({
|
||||
'canvas': {
|
||||
'width': double.nan,
|
||||
'height': 100,
|
||||
'elements': <Object?>[]
|
||||
},
|
||||
}),
|
||||
throwsA(isA<StyleValidationError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('toDataUri rejects unpaired surrogates like the JS reference', () {
|
||||
// JS encodeURIComponent throws URIError for a lone surrogate;
|
||||
// Uri.encodeComponent would silently substitute U+FFFD instead.
|
||||
final avatar = Avatar(
|
||||
Style(_minimalStyle),
|
||||
{'seed': 'x', 'title': 'broken \u{D800} title'},
|
||||
);
|
||||
|
||||
expect(avatar.toDataUri, throwsArgumentError);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// The parity fixtures are read from disk via dart:io — VM only.
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dicebear_core/src/avatar.dart';
|
||||
import 'package:dicebear_core/src/style.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'fixtures.dart';
|
||||
|
||||
// Cross-language avatar parity: every rendered SVG must match the
|
||||
// JS-generated fixture byte for byte, the resolved options must match in key
|
||||
// order AND int-vs-double typing, and the data URIs pin the percent-encoding
|
||||
// contract.
|
||||
void main() {
|
||||
if (parityFixtures() == null) {
|
||||
test(
|
||||
'avatar parity',
|
||||
() {},
|
||||
skip: 'parity fixtures not available (run inside the dicebear monorepo)',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (final name in const [
|
||||
'glass',
|
||||
'initials',
|
||||
'notionists',
|
||||
'shape-grid',
|
||||
'thumbs',
|
||||
]) {
|
||||
group(name, () {
|
||||
// The vendored fixture style, never a styles package — every language
|
||||
// renders from identical input bytes.
|
||||
final style = Style(
|
||||
jsonDecode(parityFixture('styles/$name.json')!) as Map<String, Object?>,
|
||||
);
|
||||
final cases = (jsonDecode(parityFixture('avatars/$name.json')!) as List)
|
||||
.cast<Map<String, Object?>>();
|
||||
|
||||
for (final c in cases) {
|
||||
test(c['id'], () {
|
||||
final avatar = Avatar(style, c['options'] as Map<String, Object?>?);
|
||||
final wantSvg = c['svg'] as String;
|
||||
|
||||
if (avatar.svg != wantSvg) {
|
||||
final index = _firstDiff(avatar.svg, wantSvg);
|
||||
|
||||
fail('SVG mismatch at index $index\n'
|
||||
' got: …${_context(avatar.svg, index)}…\n'
|
||||
'want: …${_context(wantSvg, index)}…');
|
||||
}
|
||||
|
||||
// Serialized compare (Python's approach): Dart shares Python's
|
||||
// `1 == 1.0` equality hazard, so a structural deep-equal would
|
||||
// hide int-vs-double drift. Encoding both sides through
|
||||
// jsonEncode also pins the key order.
|
||||
expect(
|
||||
jsonEncode(avatar.toJson()['options']),
|
||||
jsonEncode(c['resolvedOptions']),
|
||||
);
|
||||
|
||||
// Only `plain-seed` and `title-escaping` carry a data URI — the
|
||||
// encoder is byte-level, so two cases cover the alphabet.
|
||||
final dataUri = c['dataUri'];
|
||||
|
||||
if (dataUri != null) {
|
||||
expect(avatar.toDataUri(), dataUri);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the index of the first code unit where [a] and [b] differ, or the
|
||||
/// length of the shorter string if one is a prefix of the other — full-SVG
|
||||
/// diffs are unreadable otherwise (the Go suite's firstDiff).
|
||||
int _firstDiff(String a, String b) {
|
||||
final n = a.length < b.length ? a.length : b.length;
|
||||
|
||||
for (var i = 0; i < n; i++) {
|
||||
if (a.codeUnitAt(i) != b.codeUnitAt(i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
/// Returns a ±40-character window around [index] for mismatch reporting.
|
||||
String _context(String s, int index) {
|
||||
final start = (index - 40).clamp(0, s.length);
|
||||
final end = (index + 40).clamp(0, s.length);
|
||||
|
||||
return s.substring(start, end);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// The parity fixtures are read from disk via dart:io — VM only.
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dicebear_core/src/avatar.dart';
|
||||
import 'package:dicebear_core/src/error/circular_color_reference_error.dart';
|
||||
import 'package:dicebear_core/src/style.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'fixtures.dart';
|
||||
|
||||
// The circularColors cases pin the resolver's reported resolution chain
|
||||
// through Avatar construction: every port must throw its
|
||||
// CircularColorReferenceError with exactly the fixture chain.
|
||||
void main() {
|
||||
final fixture = parityFixture('validation.json');
|
||||
|
||||
if (fixture == null) {
|
||||
test(
|
||||
'circular color parity',
|
||||
() {},
|
||||
skip: 'parity fixtures not available (run inside the dicebear monorepo)',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
final cases =
|
||||
((jsonDecode(fixture) as Map<String, Object?>)['circularColors'] as List)
|
||||
.cast<Map<String, Object?>>();
|
||||
|
||||
test('fixture covers circular colors', () {
|
||||
expect(cases, isNotEmpty);
|
||||
});
|
||||
|
||||
for (final c in cases) {
|
||||
test(c['id'], () {
|
||||
// The style itself is schema-valid; only resolution detects the cycle.
|
||||
final style = Style(c['style'] as Map<String, Object?>);
|
||||
final chain = (c['chain'] as List).cast<String>();
|
||||
|
||||
try {
|
||||
Avatar(style, c['options'] as Map<String, Object?>?);
|
||||
fail('expected a CircularColorReferenceError');
|
||||
} on CircularColorReferenceError catch (error) {
|
||||
expect(error.chain, chain);
|
||||
expect(
|
||||
error.message,
|
||||
'Circular color reference: ${chain.join(' → ')}',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// The parity fixtures are read from disk via dart:io — VM only.
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dicebear_core/src/utils/color.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'fixtures.dart';
|
||||
|
||||
void main() {
|
||||
final fixture = parityFixture('colors.json');
|
||||
|
||||
group(
|
||||
'Color',
|
||||
skip: fixture == null ? 'parity fixtures not available' : false,
|
||||
() {
|
||||
late Map<String, Object?> root;
|
||||
|
||||
setUpAll(() {
|
||||
root = jsonDecode(fixture!) as Map<String, Object?>;
|
||||
});
|
||||
|
||||
List<Map<String, Object?>> cases(String section) {
|
||||
final list =
|
||||
(root[section] as List<Object?>).cast<Map<String, Object?>>();
|
||||
|
||||
expect(list, isNotEmpty, reason: section);
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
test('toHex', () {
|
||||
for (final e in cases('toHex')) {
|
||||
expect(
|
||||
Color.toHex(e['input'] as String),
|
||||
e['result'] as String,
|
||||
reason: 'toHex(${e['input']})',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('toRgbHex', () {
|
||||
for (final e in cases('toRgbHex')) {
|
||||
expect(
|
||||
Color.toRgbHex(e['input'] as String),
|
||||
e['result'] as String,
|
||||
reason: 'toRgbHex(${e['input']})',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('parseHex', () {
|
||||
for (final e in cases('parseHex')) {
|
||||
expect(
|
||||
Color.parseHex(e['input'] as String),
|
||||
[for (final v in e['result'] as List<Object?>) (v as num).toInt()],
|
||||
reason: 'parseHex(${e['input']})',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('luminance', () {
|
||||
for (final e in cases('luminance')) {
|
||||
// Exact equality: the LUT-based luminance is bit-identical across
|
||||
// ports. The `0`/`1` endpoints are JSON ints — normalize through
|
||||
// `num` like every other JSON boundary.
|
||||
expect(
|
||||
Color.luminance(e['input'] as String),
|
||||
(e['result'] as num).toDouble(),
|
||||
reason: 'luminance(${e['input']})',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('sortByContrast', () {
|
||||
for (final e in cases('sortByContrast')) {
|
||||
final candidates = (e['candidates'] as List<Object?>).cast<String>();
|
||||
final input = [...candidates];
|
||||
|
||||
// Order-sensitive: the equal-ratio case pins the stable sort.
|
||||
expect(
|
||||
Color.sortByContrast(candidates, e['refColor'] as String),
|
||||
(e['result'] as List<Object?>).cast<String>(),
|
||||
reason:
|
||||
'sortByContrast(${jsonEncode(candidates)}, ${e['refColor']})',
|
||||
);
|
||||
|
||||
// Sorting must return a new list, never mutate the caller's.
|
||||
expect(candidates, input, reason: 'input list mutated');
|
||||
}
|
||||
});
|
||||
|
||||
test('filterNotEqualTo', () {
|
||||
for (final e in cases('filterNotEqualTo')) {
|
||||
final candidates = (e['candidates'] as List<Object?>).cast<String>();
|
||||
final excluded = (e['excluded'] as List<Object?>).cast<String>();
|
||||
|
||||
expect(
|
||||
Color.filterNotEqualTo(candidates, excluded),
|
||||
(e['result'] as List<Object?>).cast<String>(),
|
||||
reason: 'filterNotEqualTo(${jsonEncode(candidates)}, '
|
||||
'${jsonEncode(excluded)})',
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// The parity fixtures are read from disk via dart:io — VM only.
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dicebear_core/src/options_descriptor.dart';
|
||||
import 'package:dicebear_core/src/style.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'fixtures.dart';
|
||||
|
||||
// Cross-language descriptor parity: one OptionsDescriptor per parity style,
|
||||
// pinning the field map (types, ranges, sorted variant lists, per-color
|
||||
// fields) that tooling builds form controls from. The serialized compare
|
||||
// pins key order and int-vs-double typing, like the avatar resolved-options
|
||||
// assertion.
|
||||
void main() {
|
||||
if (parityFixtures() == null) {
|
||||
test(
|
||||
'descriptor parity',
|
||||
() {},
|
||||
skip: 'parity fixtures not available (run inside the dicebear monorepo)',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (final name in const [
|
||||
'glass',
|
||||
'initials',
|
||||
'notionists',
|
||||
'shape-grid',
|
||||
'thumbs',
|
||||
]) {
|
||||
test(name, () {
|
||||
final style = Style(
|
||||
jsonDecode(parityFixture('styles/$name.json')!) as Map<String, Object?>,
|
||||
);
|
||||
final expected = jsonDecode(parityFixture('descriptors/$name.json')!);
|
||||
|
||||
expect(
|
||||
jsonEncode(OptionsDescriptor(style).toJson()),
|
||||
jsonEncode(expected),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,35 @@
|
||||
/// Shared access to the cross-language parity fixtures.
|
||||
///
|
||||
/// The fixtures live in the monorepo at `tests/fixtures/parity`, three levels
|
||||
/// above the package root, and are not shipped with the published package.
|
||||
/// Every parity suite therefore skips itself when the directory is absent,
|
||||
/// like the Go port's parity tests.
|
||||
///
|
||||
/// VM-only: this reads from disk via `dart:io`, so every suite importing it
|
||||
/// carries `@TestOn('vm')`. Web-eligible tests must not import this file; they
|
||||
/// embed the cases they need (see `web_parity_test.dart`).
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
/// Returns the parity fixture directory, or `null` when running outside the
|
||||
/// monorepo (published package) — callers skip their suite then.
|
||||
Directory? parityFixtures() {
|
||||
// `dart test` runs from the package root.
|
||||
final dir = Directory('../../../tests/fixtures/parity');
|
||||
|
||||
return dir.existsSync() ? dir : null;
|
||||
}
|
||||
|
||||
/// Returns the contents of the fixture file [name], or `null` when the
|
||||
/// fixture directory is absent. A missing file inside an existing fixture
|
||||
/// directory throws — that is a broken checkout, not a published package.
|
||||
String? parityFixture(String name) {
|
||||
final dir = parityFixtures();
|
||||
|
||||
if (dir == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return File('${dir.path}/$name').readAsStringSync();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// The parity fixtures are read from disk via dart:io — VM only.
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dicebear_core/src/prng/fnv1a.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'fixtures.dart';
|
||||
|
||||
void main() {
|
||||
final fixture = parityFixture('fnv1a.json');
|
||||
|
||||
group(
|
||||
'fnv1a',
|
||||
skip: fixture == null ? 'parity fixtures not available' : false,
|
||||
() {
|
||||
test('hash and hex match the parity fixture', () {
|
||||
final cases = jsonDecode(fixture!) as List<Object?>;
|
||||
|
||||
expect(cases, isNotEmpty);
|
||||
|
||||
for (final c in cases) {
|
||||
final entry = (c as Map<String, Object?>);
|
||||
final input = entry['input'] as String;
|
||||
|
||||
// Hashes exceed 2^31, so on the web they decode as integral
|
||||
// doubles — read through `num`.
|
||||
expect(
|
||||
fnv1aHash(input),
|
||||
(entry['hash'] as num).toInt(),
|
||||
reason: 'hash(${jsonEncode(input)})',
|
||||
);
|
||||
expect(
|
||||
fnv1aHex(input),
|
||||
entry['hex'] as String,
|
||||
reason: 'hex(${jsonEncode(input)})',
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// The parity fixtures are read from disk via dart:io — VM only.
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dicebear_core/src/utils/initials.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'fixtures.dart';
|
||||
|
||||
void main() {
|
||||
final fixture = parityFixture('initials.json');
|
||||
|
||||
group(
|
||||
'initialsFromSeed',
|
||||
skip: fixture == null ? 'parity fixtures not available' : false,
|
||||
() {
|
||||
test('matches the parity fixture', () {
|
||||
final cases = (jsonDecode(fixture!) as List<Object?>)
|
||||
.cast<Map<String, Object?>>();
|
||||
|
||||
expect(cases, isNotEmpty);
|
||||
|
||||
for (final e in cases) {
|
||||
final seed = e['seed'] as String;
|
||||
|
||||
// jsonEncode the seed in the failure reason — the fixture includes
|
||||
// control characters (CR, U+2028, U+2029 after `@`) that would
|
||||
// garble plain interpolation.
|
||||
expect(
|
||||
initialsFromSeed(seed),
|
||||
e['result'] as String,
|
||||
reason: 'initialsFromSeed(${jsonEncode(seed)})',
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// The parity fixtures are read from disk via dart:io — VM only.
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dicebear_core/src/prng/mulberry32.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'fixtures.dart';
|
||||
|
||||
void main() {
|
||||
final fixture = parityFixture('mulberry32.json');
|
||||
|
||||
group(
|
||||
'Mulberry32',
|
||||
skip: fixture == null ? 'parity fixtures not available' : false,
|
||||
() {
|
||||
test('float sequences and signed states match the parity fixture', () {
|
||||
final cases = jsonDecode(fixture!) as List<Object?>;
|
||||
|
||||
expect(cases, isNotEmpty);
|
||||
|
||||
for (final c in cases) {
|
||||
final entry = (c as Map<String, Object?>);
|
||||
final seed = (entry['seed'] as num).toInt();
|
||||
final sequence = entry['sequence'] as List<Object?>;
|
||||
final prng = Mulberry32(seed);
|
||||
|
||||
for (var step = 0; step < sequence.length; step++) {
|
||||
final expected = (sequence[step] as Map<String, Object?>);
|
||||
|
||||
// Floats compare with exact `==`; the fixture states are the
|
||||
// signed int32 view of the internal state.
|
||||
expect(
|
||||
prng.nextFloat(),
|
||||
(expected['float'] as num).toDouble(),
|
||||
reason: 'seed $seed, step $step: float',
|
||||
);
|
||||
expect(
|
||||
prng.state(),
|
||||
(expected['state'] as num).toInt(),
|
||||
reason: 'seed $seed, step $step: state',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// The parity fixtures are read from disk via dart:io — VM only.
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dicebear_core/src/utils/number.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'fixtures.dart';
|
||||
|
||||
void main() {
|
||||
final fixture = parityFixture('numbers.json');
|
||||
|
||||
group(
|
||||
'formatNumber',
|
||||
skip: fixture == null ? 'parity fixtures not available' : false,
|
||||
() {
|
||||
test('matches the parity fixture', () {
|
||||
final cases = jsonDecode(fixture!) as List<Object?>;
|
||||
|
||||
expect(cases, isNotEmpty);
|
||||
|
||||
for (final c in cases) {
|
||||
final entry = (c as Map<String, Object?>);
|
||||
|
||||
// The VM decodes `0`, `1`, `4096`, … as int — normalize through
|
||||
// `num` like every other JSON boundary.
|
||||
expect(
|
||||
formatNumber((entry['input'] as num).toDouble()),
|
||||
entry['output'] as String,
|
||||
reason: 'formatNumber(${jsonEncode(entry['input'])})',
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// The parity fixtures are read from disk via dart:io — VM only.
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dicebear_core/src/prng/prng.dart';
|
||||
import 'package:dicebear_core/src/range.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'fixtures.dart';
|
||||
|
||||
void main() {
|
||||
final fixture = parityFixture('prng.json');
|
||||
|
||||
group(
|
||||
'Prng',
|
||||
skip: fixture == null ? 'parity fixtures not available' : false,
|
||||
() {
|
||||
late Map<String, Object?> root;
|
||||
|
||||
setUpAll(() {
|
||||
root = jsonDecode(fixture!) as Map<String, Object?>;
|
||||
});
|
||||
|
||||
List<Map<String, Object?>> cases(String section) {
|
||||
final list =
|
||||
(root[section] as List<Object?>).cast<Map<String, Object?>>();
|
||||
|
||||
expect(list, isNotEmpty, reason: section);
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
test('getValue', () {
|
||||
for (final e in cases('getValue')) {
|
||||
expect(
|
||||
Prng(e['seed'] as String).getValue(e['key'] as String),
|
||||
(e['result'] as num).toDouble(),
|
||||
reason: '${e['seed']}:${e['key']}',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('pick', () {
|
||||
for (final e in cases('pick')) {
|
||||
final items = (e['items'] as List<Object?>).cast<String>();
|
||||
|
||||
expect(
|
||||
Prng(e['seed'] as String).pick(e['key'] as String, items),
|
||||
e['result'] as String?,
|
||||
reason: '${e['seed']}:${e['key']} ${jsonEncode(items)}',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('weightedPick', () {
|
||||
for (final e in cases('weightedPick')) {
|
||||
// Weight values mix int and double literals; normalize through
|
||||
// `num` while keeping the JSON insertion order.
|
||||
final weights = (e['weights'] as Map<String, Object?>).map(
|
||||
(k, v) => MapEntry(k, (v as num).toDouble()),
|
||||
);
|
||||
|
||||
expect(
|
||||
Prng(e['seed'] as String).weightedPick(
|
||||
e['key'] as String,
|
||||
weights,
|
||||
),
|
||||
e['result'] as String?,
|
||||
reason: '${e['seed']}:${e['key']} ${jsonEncode(e['weights'])}',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('boolean', () {
|
||||
for (final e in cases('bool')) {
|
||||
expect(
|
||||
Prng(e['seed'] as String).boolean(
|
||||
e['key'] as String,
|
||||
(e['likelihood'] as num).toDouble(),
|
||||
),
|
||||
e['result'] as bool,
|
||||
reason: '${e['seed']}:${e['key']} ${e['likelihood']}',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('float', () {
|
||||
for (final e in cases('float')) {
|
||||
expect(
|
||||
Prng(e['seed'] as String).float(
|
||||
e['key'] as String,
|
||||
_range(e['range']),
|
||||
),
|
||||
// The fixture mixes int results (`15`, `42`) with doubles.
|
||||
(e['result'] as num).toDouble(),
|
||||
reason: '${e['seed']}:${e['key']} ${jsonEncode(e['range'])}',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('integer', () {
|
||||
for (final e in cases('integer')) {
|
||||
expect(
|
||||
Prng(e['seed'] as String).integer(
|
||||
e['key'] as String,
|
||||
_range(e['range']),
|
||||
),
|
||||
(e['result'] as num).toInt(),
|
||||
reason: '${e['seed']}:${e['key']} ${jsonEncode(e['range'])}',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('shuffle', () {
|
||||
for (final e in cases('shuffle')) {
|
||||
final items = (e['items'] as List<Object?>).cast<String>();
|
||||
final input = [...items];
|
||||
|
||||
expect(
|
||||
Prng(e['seed'] as String).shuffle(e['key'] as String, items),
|
||||
(e['result'] as List<Object?>).cast<String>(),
|
||||
reason: '${e['seed']}:${e['key']} ${jsonEncode(items)}',
|
||||
);
|
||||
|
||||
// Shuffle must return a new list, never mutate the caller's.
|
||||
expect(items, input, reason: 'input list mutated');
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a [Range] from a fixture object; `step` may be absent.
|
||||
Range _range(Object? raw) {
|
||||
final map = raw as Map<String, Object?>;
|
||||
|
||||
return Range(
|
||||
min: (map['min'] as num).toDouble(),
|
||||
max: (map['max'] as num).toDouble(),
|
||||
step: (map['step'] as num?)?.toDouble(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// The parity fixtures are read from disk via dart:io — VM only.
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dicebear_core/src/error/options_validation_error.dart';
|
||||
import 'package:dicebear_core/src/error/style_validation_error.dart';
|
||||
import 'package:dicebear_core/src/style.dart';
|
||||
import 'package:dicebear_core/src/validator/options_validator.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'fixtures.dart';
|
||||
|
||||
// Cross-language validation parity: every port must accept and reject the
|
||||
// same inputs (error messages are language-specific and not compared).
|
||||
void main() {
|
||||
final fixture = parityFixture('validation.json');
|
||||
|
||||
if (fixture == null) {
|
||||
test(
|
||||
'validation parity',
|
||||
() {},
|
||||
skip: 'parity fixtures not available (run inside the dicebear monorepo)',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
final cases = jsonDecode(fixture) as Map<String, Object?>;
|
||||
final styleCases =
|
||||
(cases['styles'] as List<Object?>).cast<Map<String, Object?>>();
|
||||
final optionCases =
|
||||
(cases['options'] as List<Object?>).cast<Map<String, Object?>>();
|
||||
|
||||
test('fixture covers styles and options', () {
|
||||
expect(styleCases, isNotEmpty);
|
||||
expect(optionCases, isNotEmpty);
|
||||
});
|
||||
|
||||
// The style cases go through the Style constructor, not bare validateStyle:
|
||||
// `alias-to-unknown-component` passes the JSON Schema in every port and is
|
||||
// only rejected by the constructor's alias cross-reference check.
|
||||
group('styles', () {
|
||||
for (final c in styleCases) {
|
||||
final id = c['id'] as String;
|
||||
final definition = c['definition'] as Map<String, Object?>;
|
||||
final valid = c['valid'] as bool;
|
||||
|
||||
test(id, () {
|
||||
if (valid) {
|
||||
expect(() => Style(definition), returnsNormally);
|
||||
} else {
|
||||
expect(() => Style(definition), throwsA(isA<StyleValidationError>()));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The fixture generator validated these against the `minimal` style, but
|
||||
// options validation is style-independent in every port — validateOptions
|
||||
// is the exact equivalent. Avatar-level coverage lands with wave 2.
|
||||
group('options', () {
|
||||
for (final c in optionCases) {
|
||||
final id = c['id'] as String;
|
||||
final options = c['options'];
|
||||
final valid = c['valid'] as bool;
|
||||
|
||||
test(id, () {
|
||||
if (valid) {
|
||||
expect(() => validateOptions(options), returnsNormally);
|
||||
} else {
|
||||
expect(
|
||||
() => validateOptions(options),
|
||||
throwsA(isA<OptionsValidationError>()),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The `circularColors` cases pin the resolver's reported resolution chain
|
||||
// through Avatar construction — wave 2 covers them once Avatar exists.
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Web-platform parity guard. The fixture suites under test/parity are all
|
||||
// @TestOn('vm') because they read fixtures from disk via dart:io, so the
|
||||
// dart2js build's number formatting and 32-bit PRNG arithmetic would otherwise
|
||||
// never be asserted in CI. This suite asserts the same values on both the VM
|
||||
// and Chrome, where ints are JS doubles and the masking/imul paths matter.
|
||||
//
|
||||
// The fixture data is embedded (no dart:io) via the generated
|
||||
// embedded_fixtures.dart, kept in sync with the canonical fixtures by
|
||||
// tool/generate_web_fixtures.dart and a CI freshness check.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dicebear_core/dicebear_core.dart';
|
||||
import 'package:dicebear_core/src/prng/fnv1a.dart';
|
||||
import 'package:dicebear_core/src/prng/mulberry32.dart';
|
||||
import 'package:dicebear_core/src/utils/number.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'embedded_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
test('formatNumber matches the fixture on this platform', () {
|
||||
final cases = jsonDecode(numbersJson) as List;
|
||||
|
||||
for (final c in cases) {
|
||||
final m = c as Map<String, dynamic>;
|
||||
final input = (m['input'] as num).toDouble();
|
||||
|
||||
expect(formatNumber(input), m['output'], reason: 'input ${m['input']}');
|
||||
}
|
||||
});
|
||||
|
||||
test('fnv1a hash and hex match the fixture on this platform', () {
|
||||
final cases = jsonDecode(fnv1aJson) as List;
|
||||
|
||||
for (final c in cases) {
|
||||
final m = c as Map<String, dynamic>;
|
||||
final input = m['input'] as String;
|
||||
|
||||
expect(fnv1aHash(input), (m['hash'] as num).toInt(),
|
||||
reason: 'hash of ${jsonEncode(input)}');
|
||||
expect(fnv1aHex(input), m['hex'], reason: 'hex of ${jsonEncode(input)}');
|
||||
}
|
||||
});
|
||||
|
||||
test('mulberry32 sequence and signed state match the fixture', () {
|
||||
final cases = jsonDecode(mulberry32Json) as List;
|
||||
|
||||
for (final c in cases) {
|
||||
final m = c as Map<String, dynamic>;
|
||||
final prng = Mulberry32((m['seed'] as num).toInt());
|
||||
|
||||
for (final step in m['sequence'] as List) {
|
||||
final s = step as Map<String, dynamic>;
|
||||
|
||||
expect(prng.nextFloat(), (s['float'] as num).toDouble(),
|
||||
reason: 'seed ${m['seed']}');
|
||||
expect(prng.state(), (s['state'] as num).toInt(),
|
||||
reason: 'seed ${m['seed']} signed state');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('glass avatars render byte-identically on this platform', () {
|
||||
final style = Style.parse(glassStyle);
|
||||
final cases = jsonDecode(glassAvatars) as List;
|
||||
|
||||
for (final c in cases) {
|
||||
final m = c as Map<String, dynamic>;
|
||||
final options = ((m['options'] ?? <String, Object?>{}) as Map)
|
||||
.cast<String, Object?>();
|
||||
final avatar = Avatar(style, options);
|
||||
|
||||
expect(avatar.svg, m['svg'], reason: 'svg of ${m['id']}');
|
||||
expect(jsonEncode(avatar.toJson()['options']),
|
||||
jsonEncode(m['resolvedOptions']),
|
||||
reason: 'resolvedOptions of ${m['id']}');
|
||||
|
||||
if (m.containsKey('dataUri')) {
|
||||
expect(avatar.toDataUri(), m['dataUri'],
|
||||
reason: 'dataUri of ${m['id']}');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Generates test/parity/embedded_fixtures.dart from the canonical parity
|
||||
// fixtures.
|
||||
//
|
||||
// The web-platform parity suite cannot read fixture files at runtime (dart2js
|
||||
// has no dart:io), so the slice of fixtures it needs is embedded as Dart
|
||||
// constants. This tool copies them verbatim from tests/fixtures/parity so the
|
||||
// fixtures stay the single source of truth; CI regenerates and diffs the
|
||||
// output, so the embedded copy can never drift.
|
||||
//
|
||||
// Run from the package root: `dart run tool/generate_web_fixtures.dart`.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
void main() {
|
||||
// tool/ -> core/ -> dart/ -> src/ -> monorepo root, then tests/fixtures.
|
||||
final fixtures =
|
||||
Platform.script.resolve('../../../../tests/fixtures/parity/');
|
||||
|
||||
String read(String relative) =>
|
||||
File.fromUri(fixtures.resolve(relative)).readAsStringSync().trimRight();
|
||||
|
||||
final entries = <String, String>{
|
||||
'numbersJson': read('numbers.json'),
|
||||
'fnv1aJson': read('fnv1a.json'),
|
||||
'mulberry32Json': read('mulberry32.json'),
|
||||
'glassStyle': read('styles/glass.json'),
|
||||
'glassAvatars': read('avatars/glass.json'),
|
||||
};
|
||||
|
||||
final buffer = StringBuffer()
|
||||
..writeln('// GENERATED by tool/generate_web_fixtures.dart — do not edit '
|
||||
'by hand.')
|
||||
..writeln('//')
|
||||
..writeln('// A verbatim copy of the parity fixtures the web-platform '
|
||||
'suite needs,')
|
||||
..writeln('// embedded as constants because dart2js cannot read files at '
|
||||
'runtime.')
|
||||
..writeln('// Regenerate with `dart run tool/generate_web_fixtures.dart` '
|
||||
'whenever the')
|
||||
..writeln('// fixtures change; CI regenerates and diffs this file, so it '
|
||||
'cannot drift.')
|
||||
..writeln('library;')
|
||||
..writeln();
|
||||
|
||||
for (final entry in entries.entries) {
|
||||
buffer
|
||||
..writeln('const String ${entry.key} = ${_dartLiteral(entry.value)};')
|
||||
..writeln();
|
||||
}
|
||||
|
||||
final out = Platform.script.resolve('../test/parity/embedded_fixtures.dart');
|
||||
File.fromUri(out).writeAsStringSync(buffer.toString());
|
||||
|
||||
stdout.writeln('Wrote ${out.toFilePath()}');
|
||||
}
|
||||
|
||||
/// Renders [value] as a Dart double-quoted string literal. JSON string
|
||||
/// escaping is a subset of Dart's, except `$` (Dart interpolation) and the
|
||||
/// U+2028/U+2029 line separators (legal in a JSON string but a line break in
|
||||
/// Dart source), which are escaped explicitly.
|
||||
String _dartLiteral(String value) => jsonEncode(value)
|
||||
.replaceAll(r'$', r'\$')
|
||||
.replaceAll('\u{2028}', '\\u{2028}')
|
||||
.replaceAll('\u{2029}', '\\u{2029}');
|
||||
@@ -58,8 +58,8 @@ func (a *Avatar) ResolvedOptions() map[string]any {
|
||||
// render it — as JSON bytes.
|
||||
//
|
||||
// It is built by hand rather than via json.Marshal for byte parity with the
|
||||
// JS/PHP/Rust ports: those keep the resolved options in resolution order and do
|
||||
// not HTML-escape the SVG, whereas json.Marshal would sort the map keys
|
||||
// other ports: those keep the resolved options in resolution order and do not
|
||||
// HTML-escape the SVG, whereas json.Marshal would sort the map keys
|
||||
// alphabetically and escape <, > and & in the embedded markup.
|
||||
func (a *Avatar) JSON() ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
|
||||
// Cross-language avatar parity. Renders each shared fixture case and asserts the
|
||||
// SVG matches byte-for-byte the output committed under
|
||||
// <repo>/tests/fixtures/parity/avatars/, the same fixtures the JS, PHP, Python
|
||||
// and Rust suites render against.
|
||||
// <repo>/tests/fixtures/parity/avatars/, the same fixtures the JS, PHP,
|
||||
// Python, Rust and Dart suites render against.
|
||||
|
||||
func TestAvatarParity(t *testing.T) {
|
||||
styleNames := []string{"initials", "thumbs", "glass", "shape-grid", "notionists"}
|
||||
@@ -57,7 +57,7 @@ func TestAvatarParity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Deep-equal (order-independent), like the JS/PHP/Python/Rust
|
||||
// Deep-equal (order-independent), like the JS/PHP/Python/Rust/Dart
|
||||
// suites. Decode with UseNumber so a JSON integer (1) and float
|
||||
// (1.0) compare unequal — this pins whole-number options as JSON
|
||||
// integers, the same guarantee the Rust suite makes.
|
||||
|
||||
+8
-8
@@ -1,11 +1,11 @@
|
||||
// Package dicebear is the Go implementation of the DiceBear avatar library. It
|
||||
// generates deterministic SVG avatars from a style definition and a seed string.
|
||||
//
|
||||
// DiceBear is available for multiple languages (JavaScript, PHP, Python, Rust
|
||||
// and Go). All implementations share the same key-based PRNG and rendering
|
||||
// pipeline, producing byte-identical SVG output for the same seed, style, and
|
||||
// options — verified against the cross-language parity fixtures under
|
||||
// tests/fixtures/parity in the monorepo.
|
||||
// DiceBear is available for multiple languages (JavaScript, PHP, Python, Rust,
|
||||
// Go and Dart). All implementations share the same key-based PRNG and
|
||||
// rendering pipeline, producing byte-identical SVG output for the same seed,
|
||||
// style, and options — verified against the cross-language parity fixtures
|
||||
// under tests/fixtures/parity in the monorepo.
|
||||
//
|
||||
// This package is a thin public façade: [Avatar], [Style], [OptionsDescriptor]
|
||||
// and the typed errors ([ValidationError], [CircularColorReferenceError]). The
|
||||
@@ -14,9 +14,9 @@
|
||||
// public API. The color helpers are the one other public surface, in the
|
||||
// sub-package github.com/dicebear/dicebear-go/v10/color.
|
||||
//
|
||||
// Style definitions and option sets are the same JSON the npm, Composer, PyPI
|
||||
// and crates.io packages consume; the pure-data style definitions ship as
|
||||
// github.com/dicebear/styles/v10.
|
||||
// Style definitions and option sets are the same JSON the npm, Composer, PyPI,
|
||||
// crates.io and pub.dev packages consume; the pure-data style definitions ship
|
||||
// as github.com/dicebear/styles/v10.
|
||||
//
|
||||
// style, err := dicebear.NewStyle([]byte(styles.Adventurer))
|
||||
// if err != nil {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Package initials derives display initials from a seed string.
|
||||
//
|
||||
// Words are split by Unicode letter/mark classes (\p{L}, \p{M}) so the result
|
||||
// matches the JS, PHP, Python and Rust ports for accented and non-Latin input.
|
||||
// See https://www.regular-expressions.info/unicode.html
|
||||
// matches the JS, PHP, Python, Rust and Dart ports for accented and non-Latin
|
||||
// input. See https://www.regular-expressions.info/unicode.html
|
||||
package initials
|
||||
|
||||
import (
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
// Format formats a number for SVG output, rounded to at most 5 decimal places.
|
||||
//
|
||||
// Rounding to a fixed precision keeps the output bounded and identical across
|
||||
// the JS, PHP, Python, Rust and Go ports: every value becomes a multiple of
|
||||
// 1e-5, which has no exponential form, so the result is built from integer
|
||||
// the JS, PHP, Python, Rust, Go and Dart ports: every value becomes a multiple
|
||||
// of 1e-5, which has no exponential form, so the result is built from integer
|
||||
// arithmetic with no locale- or language-specific float stringifying.
|
||||
func Format(value float64) string {
|
||||
if math.IsNaN(value) {
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
|
||||
// Range is a closed numeric range. Min == Max is a fixed value. Step quantizes
|
||||
// the range to multiples of Step starting at Min; a nil or non-positive step
|
||||
// means continuous. It mirrors the Range type the JS, PHP, Python and Rust
|
||||
// ports share.
|
||||
// means continuous. It mirrors the Range type the JS, PHP, Python, Rust and
|
||||
// Dart ports share.
|
||||
type Range struct {
|
||||
Min float64 `json:"min"`
|
||||
Max float64 `json:"max"`
|
||||
|
||||
@@ -7,8 +7,9 @@ import (
|
||||
)
|
||||
|
||||
// Builds attribution strings and embedded RDF/Dublin Core metadata from a
|
||||
// style's meta block. Mirrors the JS, PHP, Python and Rust ports, including the
|
||||
// nullish creator fallback and the empty-string-as-absent treatment.
|
||||
// style's meta block. Mirrors the JS, PHP, Python, Rust and Dart ports,
|
||||
// including the nullish creator fallback and the empty-string-as-absent
|
||||
// treatment.
|
||||
|
||||
// nonEmptyPtr returns (value, true) only when p is non-nil and non-empty,
|
||||
// mirroring the JS falsy checks (`!sourceName` is true for both a missing field
|
||||
|
||||
@@ -177,7 +177,7 @@ func asNumberArray(value any) []float64 {
|
||||
|
||||
// toRange normalizes a range option (bare number, [n], [min, max], or absent)
|
||||
// into a *prng.Range. A bare number becomes a fixed min == max; an empty array
|
||||
// is treated as unset. Matches the JS, PHP, Python and Rust ports.
|
||||
// is treated as unset. Matches the JS, PHP, Python, Rust and Dart ports.
|
||||
func toRange(value any) *prng.Range {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
|
||||
@@ -59,8 +59,8 @@ func (r *renderer) render() (string, error) {
|
||||
}
|
||||
|
||||
// Resolve size before title and the root attributes so the resolver memo
|
||||
// records the keys in the same order as the JS/PHP/Python ports — the JSON
|
||||
// envelope emits the resolved options in first-recorded order.
|
||||
// records the keys in the same order as the other ports — the JSON envelope
|
||||
// emits the resolved options in first-recorded order.
|
||||
size := r.resolver.size()
|
||||
|
||||
var escapedTitle *string
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
// Cross-language parity: assert the primitives produce exactly the values in the
|
||||
// shared fixtures under <repo>/tests/fixtures/parity/, the same the JS, PHP,
|
||||
// Python and Rust suites run against. The tests skip gracefully when the
|
||||
// Python, Rust and Dart suites run against. The tests skip gracefully when the
|
||||
// fixtures are absent (e.g. in the split dicebear-go repo).
|
||||
|
||||
func fixturePath(t *testing.T, name string) string {
|
||||
|
||||
@@ -168,8 +168,8 @@ export class Options<D = unknown> {
|
||||
* — or a single-element array `[n]` — becomes `{ min: n, max: n }` (a fixed
|
||||
* value). An array's smaller/larger element is taken as min/max. An empty
|
||||
* array is treated as unset so the resolver applies the option's default
|
||||
* (rather than yielding `NaN` from a missing bound). Matches the PHP and
|
||||
* Python ports.
|
||||
* (rather than yielding `NaN` from a missing bound). Matches the PHP,
|
||||
* Python, Rust, Go, and Dart ports.
|
||||
*/
|
||||
#toRange(value: number | readonly number[] | undefined): Range | undefined {
|
||||
if (value === undefined) {
|
||||
|
||||
@@ -166,7 +166,7 @@ export class Prng {
|
||||
/**
|
||||
* Deduplicates by string representation, keeping the first occurrence.
|
||||
* Mirrors the cross-language sort key used by {@link #compareByCodePoint}
|
||||
* so that JS and PHP collapse the same set of inputs. `keyFn` lets
|
||||
* so that every port collapses the same set of inputs. `keyFn` lets
|
||||
* callers (e.g. {@link weightedPick}) extract the sort key from a
|
||||
* compound element.
|
||||
*/
|
||||
|
||||
@@ -545,7 +545,7 @@ export class Renderer {
|
||||
case 'initial': {
|
||||
// charAt(0) would return a lone surrogate (ill-formed XML) for
|
||||
// supplementary-plane initials; take the full first code point
|
||||
// instead, like the PHP/Python/Rust/Go ports.
|
||||
// instead, like the PHP/Python/Rust/Go/Dart ports.
|
||||
const first = this.#initials().codePointAt(0);
|
||||
return first !== undefined ? String.fromCodePoint(first) : '';
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* Formats a number for SVG output, rounded to at most 5 decimal places.
|
||||
*
|
||||
* Rounding to a fixed precision keeps the output bounded and identical across
|
||||
* the JS, PHP, and Python ports: every value becomes a multiple of 1e-5 in the
|
||||
* SVG coordinate range, which has no exponential form, so the result is built
|
||||
* from integer arithmetic (no locale- or language-specific float stringifying).
|
||||
* Five decimals is far below sub-pixel precision for any realistic canvas.
|
||||
* the JS, PHP, Python, Rust, Go, and Dart ports: every value becomes a
|
||||
* multiple of 1e-5 in the SVG coordinate range, which has no exponential form,
|
||||
* so the result is built from integer arithmetic (no locale- or
|
||||
* language-specific float stringifying). Five decimals is far below sub-pixel
|
||||
* precision for any realistic canvas.
|
||||
*/
|
||||
export class Number {
|
||||
static format(value: number): string {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user