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:
Florian Körner
2026-06-13 13:12:16 +02:00
parent 3b37bfeeff
commit 8120316ee8
117 changed files with 6237 additions and 133 deletions
+40
View File
@@ -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
+51
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7 -6
View File
@@ -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:
+1 -1
View File
@@ -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+&nbsp;avatar&nbsp;styles</span>
crafted by talented artists. Generate deterministic profile pictures via
API, JS&nbsp;library, PHP&nbsp;library, Python&nbsp;library,
Rust&nbsp;library, Go&nbsp;library &amp; CLI.
Rust&nbsp;library, Go&nbsp;library, Dart&nbsp;library &amp; 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 };
}
+3
View File
@@ -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).
+16 -7
View File
@@ -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) |
+16
View File
@@ -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
+10
View File
@@ -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
+15
View File
@@ -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/
+21
View File
@@ -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.
+71
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
include: package:lints/recommended.yaml
analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
+29
View File
@@ -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;
+126
View File
@@ -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
/// `AZ az 09` 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;
}
+220
View File
@@ -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,
};
}
+49
View File
@@ -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);
}
}
+206
View File
@@ -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 (0100, 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));
}
+9
View File
@@ -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});
}
+567
View File
@@ -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'[.*+?^${}()|[\]\\]');
+280
View File
@@ -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 (0100) 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);
}
}
+207
View File
@@ -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');
}
+26
View File
@@ -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?>)),
);
}
+31
View File
@@ -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?;
}
+110
View File
@@ -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 (0100) 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();
}
}
+46
View File
@@ -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?>)),
);
}
+33
View File
@@ -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?;
}
+400
View File
@@ -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?>;
+69
View File
@@ -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]!);
}
+113
View File
@@ -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>';
}
+105
View File
@@ -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();
}
+596
View File
@@ -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}',
};
+18
View File
@@ -0,0 +1,18 @@
/// Minimal XML escaping helper for SVG/XML text and attribute content.
library;
const _entities = <String, String>{
'&': '&amp;',
"'": '&apos;',
'"': '&quot;',
'<': '&lt;',
'>': '&gt;',
};
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');
}
+28
View File
@@ -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
+422
View File
@@ -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 &amp; B &lt;C&gt;"'));
expect(avatar.svg, contains('<title>A &amp; B &lt;C&gt;</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);
});
}
+100
View File
@@ -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('')}',
);
}
});
}
}
+110
View File
@@ -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
+35
View File
@@ -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();
}
+44
View File
@@ -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'])})',
);
}
});
},
);
}
+144
View File
@@ -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}');
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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 {
+2 -2
View File
@@ -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 (
+2 -2
View File
@@ -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) {
+2 -2
View File
@@ -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"`
+3 -2
View File
@@ -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
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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) {
+1 -1
View File
@@ -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.
*/
+1 -1
View File
@@ -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) : '';
}
+5 -4
View File
@@ -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