feat: improve native preview (#15223)
#### PR Dependency Tree * **PR #15223** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Improvements** * Updated Mermaid and Typst preview rendering in native/mobile apps to use a shared preview implementation. * Improved production Release builds with additional Rust release tuning and enhanced iOS Release stripping/symbol settings. * Enabled stronger release output optimization for the native package build. * **Bug Fixes** * Strengthened handling of unsupported self-hosted server versions during login preflight. * Refreshed unsupported-version messaging with localized text and a direct upgrade link. * **Tests** * Added coverage for login preflight behavior when the server version is unsupported. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
15
.github/workflows/build-test.yml
vendored
15
.github/workflows/build-test.yml
vendored
@@ -171,6 +171,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- mobile-native-build-filter
|
- mobile-native-build-filter
|
||||||
|
env:
|
||||||
|
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
|
||||||
|
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
||||||
|
CARGO_PROFILE_RELEASE_LTO: 'thin'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
@@ -248,6 +252,9 @@ jobs:
|
|||||||
- mobile-native-build-filter
|
- mobile-native-build-filter
|
||||||
env:
|
env:
|
||||||
AFFINE_ANDROID_NATIVE_TARGET: x86_64
|
AFFINE_ANDROID_NATIVE_TARGET: x86_64
|
||||||
|
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
|
||||||
|
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
||||||
|
CARGO_PROFILE_RELEASE_LTO: 'thin'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
@@ -656,7 +663,9 @@ jobs:
|
|||||||
name: Build AFFiNE native (x86_64-unknown-linux-gnu)
|
name: Build AFFiNE native (x86_64-unknown-linux-gnu)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
|
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
|
||||||
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
||||||
|
CARGO_PROFILE_RELEASE_LTO: 'thin'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
@@ -688,7 +697,9 @@ jobs:
|
|||||||
name: Build AFFiNE native (${{ matrix.spec.target }})
|
name: Build AFFiNE native (${{ matrix.spec.target }})
|
||||||
runs-on: ${{ matrix.spec.os }}
|
runs-on: ${{ matrix.spec.os }}
|
||||||
env:
|
env:
|
||||||
|
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
|
||||||
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
||||||
|
CARGO_PROFILE_RELEASE_LTO: 'thin'
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -729,7 +740,9 @@ jobs:
|
|||||||
name: Build AFFiNE native (${{ matrix.spec.target }})
|
name: Build AFFiNE native (${{ matrix.spec.target }})
|
||||||
runs-on: ${{ matrix.spec.os }}
|
runs-on: ${{ matrix.spec.os }}
|
||||||
env:
|
env:
|
||||||
|
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
|
||||||
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
||||||
|
CARGO_PROFILE_RELEASE_LTO: 'thin'
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -776,7 +789,9 @@ jobs:
|
|||||||
name: Build Server native
|
name: Build Server native
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
|
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
|
||||||
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
||||||
|
CARGO_PROFILE_RELEASE_LTO: 'thin'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
|
|||||||
2
.github/workflows/copilot-test.yml
vendored
2
.github/workflows/copilot-test.yml
vendored
@@ -8,7 +8,9 @@ jobs:
|
|||||||
name: Build Server native
|
name: Build Server native
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
|
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
|
||||||
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
||||||
|
CARGO_PROFILE_RELEASE_LTO: 'thin'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
|
|||||||
862
Cargo.lock
generated
862
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
12
Cargo.toml
@@ -19,6 +19,7 @@ resolver = "3"
|
|||||||
affine_doc_loader = "0.1.2"
|
affine_doc_loader = "0.1.2"
|
||||||
affine_importer = "0.1.1"
|
affine_importer = "0.1.1"
|
||||||
affine_nbstore = { path = "./packages/frontend/native/nbstore" }
|
affine_nbstore = { path = "./packages/frontend/native/nbstore" }
|
||||||
|
affine_preview = { version = "0.1.0", default-features = false }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
assert-json-diff = "2.0"
|
assert-json-diff = "2.0"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
@@ -52,7 +53,6 @@ resolver = "3"
|
|||||||
lru = "0.16"
|
lru = "0.16"
|
||||||
matroska = "0.30"
|
matroska = "0.30"
|
||||||
memory-indexer = "0.3.1"
|
memory-indexer = "0.3.1"
|
||||||
mermaid-rs-renderer = { git = "https://github.com/toeverything/mermaid-rs-renderer", rev = "fba9097", default-features = false }
|
|
||||||
mimalloc = "0.1"
|
mimalloc = "0.1"
|
||||||
mp4parse = "0.17"
|
mp4parse = "0.17"
|
||||||
nanoid = "0.4"
|
nanoid = "0.4"
|
||||||
@@ -96,14 +96,6 @@ resolver = "3"
|
|||||||
tiktoken-rs = "0.7"
|
tiktoken-rs = "0.7"
|
||||||
tokio = "1.45"
|
tokio = "1.45"
|
||||||
typed-path = "0.12.3"
|
typed-path = "0.12.3"
|
||||||
typst = "0.14.2"
|
|
||||||
typst-as-lib = { version = "0.15.4", default-features = false, features = [
|
|
||||||
"packages",
|
|
||||||
"typst-kit-embed-fonts",
|
|
||||||
"typst-kit-fonts",
|
|
||||||
"ureq",
|
|
||||||
] }
|
|
||||||
typst-svg = "0.14.2"
|
|
||||||
uniffi = "0.29"
|
uniffi = "0.29"
|
||||||
url = { version = "2.5" }
|
url = { version = "2.5" }
|
||||||
uuid = "1.8"
|
uuid = "1.8"
|
||||||
@@ -129,7 +121,7 @@ opt-level = 3
|
|||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
lto = true
|
lto = "fat"
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
strip = "symbols"
|
strip = "symbols"
|
||||||
|
|
||||||
|
|||||||
@@ -566,8 +566,9 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Distribution: TOEVERYTHING PTE. LTD. (73YMMDVT2M)";
|
CODE_SIGN_IDENTITY = "Apple Distribution: TOEVERYTHING PTE. LTD. (73YMMDVT2M)";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEPLOYMENT_POSTPROCESSING = YES;
|
||||||
DEVELOPMENT_TEAM = 73YMMDVT2M;
|
DEVELOPMENT_TEAM = 73YMMDVT2M;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
@@ -587,6 +588,7 @@
|
|||||||
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
|
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
STRIP_INSTALLED_PRODUCT = YES;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Server } from '@affine/core/modules/cloud';
|
import type { Server } from '@affine/core/modules/cloud';
|
||||||
import { MIN_SUPPORTED_SERVER_VERSION } from '@affine/core/modules/cloud/stores/server-config';
|
import { MIN_SUPPORTED_SERVER_VERSION } from '@affine/core/modules/cloud/stores/server-config';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
import { useLiveData } from '@toeverything/infra';
|
import { useLiveData } from '@toeverything/infra';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import semver from 'semver';
|
import semver from 'semver';
|
||||||
@@ -7,7 +8,7 @@ import semver from 'semver';
|
|||||||
const rules = [
|
const rules = [
|
||||||
{
|
{
|
||||||
min: MIN_SUPPORTED_SERVER_VERSION,
|
min: MIN_SUPPORTED_SERVER_VERSION,
|
||||||
tip: (receivedVersion: string, requiredVersion: string) => (
|
tip: (message: string) => (
|
||||||
<div>
|
<div>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
@@ -16,18 +17,14 @@ const rules = [
|
|||||||
lineHeight: '22px',
|
lineHeight: '22px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Your server version{' '}
|
{message}
|
||||||
<b style={{ fontWeight: 600 }}>{receivedVersion}</b> is not compatible
|
|
||||||
with current client. Please upgrade your server to{' '}
|
|
||||||
<b style={{ fontWeight: 600 }}>{requiredVersion}</b> or higher to use
|
|
||||||
this client.
|
|
||||||
</p>
|
</p>
|
||||||
<div style={{ marginTop: '12px', color: cssVarV2.text.primary }}>
|
|
||||||
<span style={{ fontWeight: 500 }}>Instructions:</span>
|
|
||||||
<br />
|
|
||||||
<a
|
<a
|
||||||
|
href="https://docs.affine.pro/self-host-affine/install/upgrade"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
style={{
|
style={{
|
||||||
whiteSpace: 'break-spaces',
|
color: cssVarV2.text.primary,
|
||||||
wordBreak: 'break-all',
|
wordBreak: 'break-all',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
lineHeight: '16px',
|
lineHeight: '16px',
|
||||||
@@ -36,7 +33,6 @@ const rules = [
|
|||||||
https://docs.affine.pro/self-host-affine/install/upgrade
|
https://docs.affine.pro/self-host-affine/install/upgrade
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -45,12 +41,17 @@ const rules = [
|
|||||||
* Return the error tip if the server version is not meet the requirement
|
* Return the error tip if the server version is not meet the requirement
|
||||||
*/
|
*/
|
||||||
export const useSelfhostLoginVersionGuard = (server: Server) => {
|
export const useSelfhostLoginVersionGuard = (server: Server) => {
|
||||||
|
const t = useI18n();
|
||||||
const serverVersion =
|
const serverVersion =
|
||||||
useLiveData(server.config$.selector(c => c.version)) ?? '0.0.0';
|
useLiveData(server.config$.selector(c => c.version)) ?? '0.0.0';
|
||||||
|
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
if (semver.lt(serverVersion, rule.min)) {
|
if (semver.lt(serverVersion, rule.min)) {
|
||||||
return rule.tip(serverVersion, rule.min);
|
return rule.tip(
|
||||||
|
t['error.UNSUPPORTED_SERVER_VERSION']({
|
||||||
|
requiredVersion: `>=${rule.min}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ function createStore({
|
|||||||
framework.service(GraphQLService, {} as any);
|
framework.service(GraphQLService, {} as any);
|
||||||
framework.impl(GlobalState, {} as any);
|
framework.impl(GlobalState, {} as any);
|
||||||
framework.service(ServerService, {
|
framework.service(ServerService, {
|
||||||
server: { id: 'test-server' },
|
server: {
|
||||||
|
id: 'test-server',
|
||||||
|
['config$']: { value: { version: '0.27.0' } },
|
||||||
|
},
|
||||||
} as any);
|
} as any);
|
||||||
framework.impl(AuthProvider, {} as any);
|
framework.impl(AuthProvider, {} as any);
|
||||||
framework.service(NbstoreService, {
|
framework.service(NbstoreService, {
|
||||||
@@ -37,6 +40,36 @@ function createStore({
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('AuthStore', () => {
|
describe('AuthStore', () => {
|
||||||
|
test('validates login preflight responses', async () => {
|
||||||
|
const validResponse = {
|
||||||
|
registered: true,
|
||||||
|
methods: {
|
||||||
|
password: { available: true },
|
||||||
|
magicLink: { available: true },
|
||||||
|
oauth: { available: false, providers: [] },
|
||||||
|
passkey: { available: false, discoverable: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => validResponse })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ registered: true, methods: {} }),
|
||||||
|
});
|
||||||
|
const store = createStore({ fetch, request: vi.fn() });
|
||||||
|
|
||||||
|
await expect(store.checkUserByEmail('user@affine.pro')).resolves.toEqual(
|
||||||
|
validResponse
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
store.checkUserByEmail('user@affine.pro')
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
name: 'UNSUPPORTED_SERVER_VERSION',
|
||||||
|
data: { serverVersion: '0.27.0' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('loads account profile from realtime after auth session bootstrap', async () => {
|
test('loads account profile from realtime after auth session bootstrap', async () => {
|
||||||
const authMethods = {
|
const authMethods = {
|
||||||
password: { bound: true },
|
password: { bound: true },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import type { CurrentUserProfileSnapshot } from '@affine/realtime';
|
import type { CurrentUserProfileSnapshot } from '@affine/realtime';
|
||||||
import { Store } from '@toeverything/infra';
|
import { Store } from '@toeverything/infra';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { GlobalState, NbstoreService } from '../../storage';
|
import type { GlobalState, NbstoreService } from '../../storage';
|
||||||
import type { AuthSessionInfo } from '../entities/session';
|
import type { AuthSessionInfo } from '../entities/session';
|
||||||
@@ -14,6 +15,23 @@ import type { AuthProvider, SignInUserInfo } from '../provider/auth';
|
|||||||
import type { FetchService } from '../services/fetch';
|
import type { FetchService } from '../services/fetch';
|
||||||
import type { GraphQLService } from '../services/graphql';
|
import type { GraphQLService } from '../services/graphql';
|
||||||
import type { ServerService } from '../services/server';
|
import type { ServerService } from '../services/server';
|
||||||
|
import { createUnsupportedServerVersionError } from './server-config';
|
||||||
|
|
||||||
|
const AuthPreflightResponseSchema = z.object({
|
||||||
|
registered: z.boolean(),
|
||||||
|
methods: z.object({
|
||||||
|
password: z.object({ available: z.boolean() }),
|
||||||
|
magicLink: z.object({ available: z.boolean() }),
|
||||||
|
oauth: z.object({
|
||||||
|
available: z.boolean(),
|
||||||
|
providers: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
passkey: z.object({
|
||||||
|
available: z.boolean(),
|
||||||
|
discoverable: z.boolean(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export interface AccountProfile extends CurrentUserProfileSnapshot {
|
export interface AccountProfile extends CurrentUserProfileSnapshot {
|
||||||
authMethods?: {
|
authMethods?: {
|
||||||
@@ -201,17 +219,14 @@ export class AuthStore extends Store {
|
|||||||
throw new Error(`Failed to check user by email: ${email}`);
|
throw new Error(`Failed to check user by email: ${email}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await res.json()) as {
|
const data = AuthPreflightResponseSchema.safeParse(await res.json());
|
||||||
registered: boolean;
|
if (!data.success) {
|
||||||
methods: {
|
throw createUnsupportedServerVersionError(
|
||||||
password: { available: boolean };
|
this.serverService.server.config$.value.version
|
||||||
magicLink: { available: boolean };
|
);
|
||||||
oauth: { available: boolean; providers: string[] };
|
}
|
||||||
passkey: { available: boolean; discoverable: boolean };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAccount() {
|
async deleteAccount() {
|
||||||
|
|||||||
@@ -40,11 +40,8 @@ objc2-foundation = { workspace = true, features = [
|
|||||||
homedir = { workspace = true }
|
homedir = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
|
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
|
||||||
|
affine_preview = { workspace = true, features = ["mermaid", "typst"] }
|
||||||
lru = { workspace = true }
|
lru = { workspace = true }
|
||||||
mermaid-rs-renderer = { workspace = true }
|
|
||||||
typst = { workspace = true }
|
|
||||||
typst-as-lib = { workspace = true }
|
|
||||||
typst-svg = { workspace = true }
|
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
uniffi = { workspace = true, features = ["build"] }
|
uniffi = { workspace = true, features = ["build"] }
|
||||||
|
|||||||
@@ -1,67 +1,9 @@
|
|||||||
use std::{borrow::Cow, path::PathBuf};
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use mermaid_rs_renderer::RenderOptions;
|
use affine_preview::{MermaidRenderOptions, TypstRenderOptions};
|
||||||
use typst::{
|
|
||||||
diag::FileResult,
|
|
||||||
foundations::Bytes,
|
|
||||||
layout::{Abs, PagedDocument},
|
|
||||||
syntax::{FileId, Source},
|
|
||||||
};
|
|
||||||
use typst_as_lib::{
|
|
||||||
TypstEngine,
|
|
||||||
cached_file_resolver::{CachedFileResolver, IntoCachedFileResolver},
|
|
||||||
file_resolver::FileResolver,
|
|
||||||
package_resolver::{FileSystemCache, PackageResolver},
|
|
||||||
typst_kit_options::TypstKitFontOptions,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{Result, UniffiError};
|
use crate::{Result, UniffiError};
|
||||||
|
|
||||||
const TYPST_PACKAGE_CACHE_DIR: &str = "typst-package-cache";
|
|
||||||
|
|
||||||
enum MobileTypstPackageResolver {
|
|
||||||
FileSystem(CachedFileResolver<PackageResolver<FileSystemCache>>),
|
|
||||||
InMemory(CachedFileResolver<PackageResolver<typst_as_lib::package_resolver::InMemoryCache>>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FileResolver for MobileTypstPackageResolver {
|
|
||||||
fn resolve_binary(&self, id: FileId) -> FileResult<Cow<'_, Bytes>> {
|
|
||||||
match self {
|
|
||||||
Self::FileSystem(resolver) => resolver.resolve_binary(id),
|
|
||||||
Self::InMemory(resolver) => resolver.resolve_binary(id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_source(&self, id: FileId) -> FileResult<Cow<'_, Source>> {
|
|
||||||
match self {
|
|
||||||
Self::FileSystem(resolver) => resolver.resolve_source(id),
|
|
||||||
Self::InMemory(resolver) => resolver.resolve_source(id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_mermaid_render_options(
|
|
||||||
theme: Option<String>,
|
|
||||||
font_family: Option<String>,
|
|
||||||
font_size: Option<f64>,
|
|
||||||
) -> RenderOptions {
|
|
||||||
let mut render_options = match theme.as_deref() {
|
|
||||||
Some("default") => RenderOptions::mermaid_default(),
|
|
||||||
Some("dark") | Some("modern") => RenderOptions::modern(),
|
|
||||||
_ => RenderOptions::modern(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(font_family) = font_family {
|
|
||||||
render_options.theme.font_family = font_family;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(font_size) = font_size {
|
|
||||||
render_options.theme.font_size = font_size as f32;
|
|
||||||
}
|
|
||||||
|
|
||||||
render_options
|
|
||||||
}
|
|
||||||
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
pub fn render_mermaid_preview_svg(
|
pub fn render_mermaid_preview_svg(
|
||||||
code: String,
|
code: String,
|
||||||
@@ -69,38 +11,15 @@ pub fn render_mermaid_preview_svg(
|
|||||||
font_family: Option<String>,
|
font_family: Option<String>,
|
||||||
font_size: Option<f64>,
|
font_size: Option<f64>,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let render_options = resolve_mermaid_render_options(theme, font_family, font_size);
|
affine_preview::render_mermaid_svg(
|
||||||
|
&code,
|
||||||
mermaid_rs_renderer::render_with_options(&code, render_options).map_err(|error| UniffiError::Err(error.to_string()))
|
MermaidRenderOptions {
|
||||||
}
|
theme,
|
||||||
|
font_family,
|
||||||
fn normalize_typst_svg(svg: String) -> String {
|
font_size,
|
||||||
let mut svg = svg;
|
},
|
||||||
let page_background_marker = r##"<path class="typst-shape""##;
|
)
|
||||||
let mut cursor = 0;
|
.map_err(|error| UniffiError::Err(error.to_string()))
|
||||||
|
|
||||||
while let Some(relative_idx) = svg[cursor..].find(page_background_marker) {
|
|
||||||
let idx = cursor + relative_idx;
|
|
||||||
let rest = &svg[idx..];
|
|
||||||
let Some(relative_end) = rest.find("/>") else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
|
|
||||||
let end = idx + relative_end + 2;
|
|
||||||
let path_fragment = &svg[idx..end];
|
|
||||||
let is_page_background_path =
|
|
||||||
path_fragment.contains(r#"d="M 0 0v "#) && path_fragment.contains(r#" h "#) && path_fragment.contains(r#" v -"#);
|
|
||||||
|
|
||||||
if is_page_background_path {
|
|
||||||
svg.replace_range(idx..end, "");
|
|
||||||
cursor = idx;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
cursor = end;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_typst_font_dirs(font_dirs: Option<Vec<String>>) -> Vec<PathBuf> {
|
fn resolve_typst_font_dirs(font_dirs: Option<Vec<String>>) -> Vec<PathBuf> {
|
||||||
@@ -109,48 +28,18 @@ fn resolve_typst_font_dirs(font_dirs: Option<Vec<String>>) -> Vec<PathBuf> {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_typst_package_resolver(cache_dir: Option<String>) -> Result<MobileTypstPackageResolver> {
|
|
||||||
let resolver = match cache_dir {
|
|
||||||
Some(cache_dir) => {
|
|
||||||
let cache_dir = PathBuf::from(cache_dir).join(TYPST_PACKAGE_CACHE_DIR);
|
|
||||||
std::fs::create_dir_all(&cache_dir).map_err(|error| UniffiError::Err(error.to_string()))?;
|
|
||||||
MobileTypstPackageResolver::FileSystem(
|
|
||||||
PackageResolver::builder()
|
|
||||||
.cache(FileSystemCache(cache_dir))
|
|
||||||
.build()
|
|
||||||
.into_cached(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
MobileTypstPackageResolver::InMemory(PackageResolver::builder().with_in_memory_cache().build().into_cached())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(resolver)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
pub fn render_typst_preview_svg(
|
pub fn render_typst_preview_svg(
|
||||||
code: String,
|
code: String,
|
||||||
font_dirs: Option<Vec<String>>,
|
font_dirs: Option<Vec<String>>,
|
||||||
cache_dir: Option<String>,
|
cache_dir: Option<String>,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let search_options = TypstKitFontOptions::new()
|
affine_preview::render_typst_svg(
|
||||||
.include_system_fonts(false)
|
&code,
|
||||||
.include_embedded_fonts(true)
|
TypstRenderOptions {
|
||||||
.include_dirs(resolve_typst_font_dirs(font_dirs));
|
font_dirs: resolve_typst_font_dirs(font_dirs),
|
||||||
let package_resolver = resolve_typst_package_resolver(cache_dir)?;
|
cache_dir: cache_dir.map(PathBuf::from),
|
||||||
|
},
|
||||||
let engine = TypstEngine::builder()
|
)
|
||||||
.main_file(code)
|
.map_err(|error| UniffiError::Err(error.to_string()))
|
||||||
.search_fonts_with(search_options)
|
|
||||||
.add_file_resolver(package_resolver)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let document = engine
|
|
||||||
.compile::<PagedDocument>()
|
|
||||||
.output
|
|
||||||
.map_err(|error| UniffiError::Err(error.to_string()))?;
|
|
||||||
|
|
||||||
Ok(normalize_typst_svg(typst_svg::svg_merged(&document, Abs::pt(0.0))))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,10 +34,7 @@ tokio = { workspace = true, features = ["full"] }
|
|||||||
typed-path = { workspace = true }
|
typed-path = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
mermaid-rs-renderer = { workspace = true }
|
affine_preview = { workspace = true, features = ["mermaid", "typst"] }
|
||||||
typst = { workspace = true }
|
|
||||||
typst-as-lib = { workspace = true }
|
|
||||||
typst-svg = { workspace = true }
|
|
||||||
|
|
||||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||||
mimalloc = { workspace = true }
|
mimalloc = { workspace = true }
|
||||||
|
|||||||
@@ -53,5 +53,6 @@ tokio = { workspace = true, features = ["full"] }
|
|||||||
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
assert-json-diff = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
uuid = { workspace = true, features = ["v4"] }
|
uuid = { workspace = true, features = ["v4"] }
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ mod tests {
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use affine_doc_loader::ParseError;
|
use affine_doc_loader::ParseError;
|
||||||
|
use assert_json_diff::assert_json_eq;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
@@ -319,9 +320,16 @@ mod tests {
|
|||||||
|
|
||||||
let result = storage.crawl_doc_data("demo-doc").await.unwrap();
|
let result = storage.crawl_doc_data("demo-doc").await.unwrap();
|
||||||
|
|
||||||
let expected: Value = serde_json::from_slice(DEMO_JSON).unwrap();
|
let mut expected: Value = serde_json::from_slice(DEMO_JSON).unwrap();
|
||||||
let actual = serde_json::to_value(&result).unwrap();
|
let mut actual = serde_json::to_value(&result).unwrap();
|
||||||
assert_eq!(expected, actual);
|
for document in [&mut expected, &mut actual] {
|
||||||
|
for block in document["blocks"].as_array_mut().unwrap() {
|
||||||
|
if let Some(additional) = block["additional"].as_str() {
|
||||||
|
block["additional"] = serde_json::from_str(additional).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_json_eq!(expected, actual);
|
||||||
|
|
||||||
storage.close().await;
|
storage.close().await;
|
||||||
cleanup(&db_path).await;
|
cleanup(&db_path).await;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"artifacts": "napi artifacts",
|
"artifacts": "napi artifacts",
|
||||||
"build": "napi build -p affine_native --platform --release",
|
"build": "napi build -p affine_native --platform --release --strip",
|
||||||
"build:debug": "napi build -p affine_native --platform",
|
"build:debug": "napi build -p affine_native --platform",
|
||||||
"universal": "napi universal",
|
"universal": "napi universal",
|
||||||
"test": "ava --no-worker-threads --concurrency=2",
|
"test": "ava --no-worker-threads --concurrency=2",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use mermaid_rs_renderer::RenderOptions;
|
use affine_preview::{
|
||||||
|
MermaidRenderOptions as PreviewMermaidRenderOptions, TypstRenderOptions as PreviewTypstRenderOptions,
|
||||||
|
};
|
||||||
use napi::{Error, Result};
|
use napi::{Error, Result};
|
||||||
use napi_derive::napi;
|
use napi_derive::napi;
|
||||||
use typst::layout::{Abs, PagedDocument};
|
|
||||||
use typst_as_lib::{TypstEngine, typst_kit_options::TypstKitFontOptions};
|
|
||||||
|
|
||||||
#[napi(object)]
|
#[napi(object)]
|
||||||
pub struct MermaidRenderOptions {
|
pub struct MermaidRenderOptions {
|
||||||
@@ -24,30 +24,21 @@ pub struct MermaidRenderResult {
|
|||||||
pub svg: String,
|
pub svg: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_mermaid_render_options(options: Option<MermaidRenderOptions>) -> RenderOptions {
|
|
||||||
let mut render_options = match options.as_ref().and_then(|options| options.theme.as_deref()) {
|
|
||||||
Some("default") => RenderOptions::mermaid_default(),
|
|
||||||
Some("dark") | Some("modern") => RenderOptions::modern(),
|
|
||||||
_ => RenderOptions::modern(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(options) = options {
|
|
||||||
if let Some(font_family) = options.font_family {
|
|
||||||
render_options.theme.font_family = font_family;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(font_size) = options.font_size {
|
|
||||||
render_options.theme.font_size = font_size as f32;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render_options
|
|
||||||
}
|
|
||||||
|
|
||||||
#[napi]
|
#[napi]
|
||||||
pub fn render_mermaid_svg(request: MermaidRenderRequest) -> Result<MermaidRenderResult> {
|
pub fn render_mermaid_svg(request: MermaidRenderRequest) -> Result<MermaidRenderResult> {
|
||||||
let render_options = resolve_mermaid_render_options(request.options);
|
let options = request.options.unwrap_or(MermaidRenderOptions {
|
||||||
let svg = mermaid_rs_renderer::render_with_options(&request.code, render_options)
|
theme: None,
|
||||||
|
font_family: None,
|
||||||
|
font_size: None,
|
||||||
|
});
|
||||||
|
let svg = affine_preview::render_mermaid_svg(
|
||||||
|
&request.code,
|
||||||
|
PreviewMermaidRenderOptions {
|
||||||
|
theme: options.theme,
|
||||||
|
font_family: options.font_family,
|
||||||
|
font_size: options.font_size,
|
||||||
|
},
|
||||||
|
)
|
||||||
.map_err(|error| Error::from_reason(error.to_string()))?;
|
.map_err(|error| Error::from_reason(error.to_string()))?;
|
||||||
|
|
||||||
Ok(MermaidRenderResult { svg })
|
Ok(MermaidRenderResult { svg })
|
||||||
@@ -106,87 +97,16 @@ fn resolve_typst_font_dirs(options: &Option<TypstRenderOptions>) -> Vec<PathBuf>
|
|||||||
font_dirs
|
font_dirs
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_typst_svg(svg: String) -> String {
|
|
||||||
let mut svg = svg;
|
|
||||||
let page_background_marker = r##"<path class="typst-shape""##;
|
|
||||||
let mut cursor = 0;
|
|
||||||
|
|
||||||
while let Some(relative_idx) = svg[cursor..].find(page_background_marker) {
|
|
||||||
let idx = cursor + relative_idx;
|
|
||||||
let rest = &svg[idx..];
|
|
||||||
let Some(relative_end) = rest.find("/>") else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
|
|
||||||
let end = idx + relative_end + 2;
|
|
||||||
let path_fragment = &svg[idx..end];
|
|
||||||
let is_page_background_path =
|
|
||||||
path_fragment.contains(r#"d="M 0 0v "#) && path_fragment.contains(r#" h "#) && path_fragment.contains(r#" v -"#);
|
|
||||||
|
|
||||||
if is_page_background_path {
|
|
||||||
svg.replace_range(idx..end, "");
|
|
||||||
cursor = idx;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
cursor = end;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg
|
|
||||||
}
|
|
||||||
|
|
||||||
#[napi]
|
#[napi]
|
||||||
pub fn render_typst_svg(request: TypstRenderRequest) -> Result<TypstRenderResult> {
|
pub fn render_typst_svg(request: TypstRenderRequest) -> Result<TypstRenderResult> {
|
||||||
let font_dirs = resolve_typst_font_dirs(&request.options);
|
let font_dirs = resolve_typst_font_dirs(&request.options);
|
||||||
let search_options = TypstKitFontOptions::new()
|
let svg = affine_preview::render_typst_svg(
|
||||||
.include_system_fonts(false)
|
&request.code,
|
||||||
.include_embedded_fonts(true)
|
PreviewTypstRenderOptions {
|
||||||
.include_dirs(font_dirs);
|
font_dirs,
|
||||||
|
cache_dir: None,
|
||||||
let engine = TypstEngine::builder()
|
},
|
||||||
.main_file(request.code)
|
)
|
||||||
.search_fonts_with(search_options)
|
|
||||||
.with_package_file_resolver()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let document = engine
|
|
||||||
.compile::<PagedDocument>()
|
|
||||||
.output
|
|
||||||
.map_err(|error| Error::from_reason(error.to_string()))?;
|
.map_err(|error| Error::from_reason(error.to_string()))?;
|
||||||
|
|
||||||
let svg = normalize_typst_svg(typst_svg::svg_merged(&document, Abs::pt(0.0)));
|
|
||||||
Ok(TypstRenderResult { svg })
|
Ok(TypstRenderResult { svg })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::normalize_typst_svg;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_typst_svg_removes_all_backgrounds() {
|
|
||||||
let input = r##"<svg>
|
|
||||||
<path class="typst-shape" fill="#ffffff" fill-rule="nonzero" d="M 0 0v 10 h 10 v -10 Z "/>
|
|
||||||
<g></g>
|
|
||||||
<path class="typst-shape" fill="#ffffff" fill-rule="nonzero" d="M 0 0v 10 h 10 v -10 Z "/>
|
|
||||||
<g transform="matrix(1 0 0 1 0 10)"></g>
|
|
||||||
</svg>"##
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let normalized = normalize_typst_svg(input);
|
|
||||||
let retained = normalized
|
|
||||||
.matches(r##"<path class="typst-shape" fill="#ffffff" fill-rule="nonzero""##)
|
|
||||||
.count();
|
|
||||||
assert_eq!(retained, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_typst_svg_keeps_non_background_paths() {
|
|
||||||
let input = r##"<svg>
|
|
||||||
<path class="typst-shape" fill="#000000" fill-rule="nonzero" d="M 1 2 L 3 4 Z "/>
|
|
||||||
</svg>"##
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let normalized = normalize_typst_svg(input);
|
|
||||||
assert!(normalized.contains(r##"d="M 1 2 L 3 4 Z ""##));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user