Mayo commited on
Commit
947d56c
·
unverified ·
1 Parent(s): d211cf1

feat: google fonts

Browse files
.gitignore CHANGED
@@ -30,6 +30,8 @@ target/
30
 
31
  # Datasets
32
  data/
 
 
33
 
34
  # model results
35
  runs/
 
30
 
31
  # Datasets
32
  data/
33
+ !koharu-app/data/
34
+ !koharu-app/data/google-fonts-catalog.json
35
 
36
  # model results
37
  runs/
koharu-app/data/google-fonts-catalog.json ADDED
The diff for this file is too large to render. See raw diff
 
koharu-app/src/engine.rs CHANGED
@@ -933,7 +933,7 @@ struct KoharuRenderEngine;
933
  #[async_trait]
934
  impl Engine for KoharuRenderEngine {
935
  async fn run(&self, doc: &Document, res: &AppResources) -> Result<Patch> {
936
- render_document(res, &doc.id, None, None, None, None).await?;
937
  Ok(Patch::none())
938
  }
939
  }
@@ -970,10 +970,10 @@ pub async fn render_document(
970
  text_block_index: Option<usize>,
971
  shader_effect: Option<TextShaderEffect>,
972
  shader_stroke: Option<TextStrokeStyle>,
973
- font_family: Option<&str>,
974
  ) -> Result<()> {
975
  let renderer = get_renderer(res).await?;
976
  let doc = res.storage.page(document_id).await?;
 
977
  let source = res.storage.images.load(&doc.source)?;
978
  let inpainted = doc
979
  .inpainted
@@ -997,7 +997,7 @@ pub async fn render_document(
997
  text_block_index,
998
  shader_effect: shader_effect.unwrap_or_default(),
999
  shader_stroke,
1000
- font_family,
1001
  bubbles: &bubbles,
1002
  image_width: doc.width,
1003
  image_height: doc.height,
 
933
  #[async_trait]
934
  impl Engine for KoharuRenderEngine {
935
  async fn run(&self, doc: &Document, res: &AppResources) -> Result<Patch> {
936
+ render_document(res, &doc.id, None, None, None).await?;
937
  Ok(Patch::none())
938
  }
939
  }
 
970
  text_block_index: Option<usize>,
971
  shader_effect: Option<TextShaderEffect>,
972
  shader_stroke: Option<TextStrokeStyle>,
 
973
  ) -> Result<()> {
974
  let renderer = get_renderer(res).await?;
975
  let doc = res.storage.page(document_id).await?;
976
+ let document_font = doc.style.as_ref().and_then(|s| s.default_font.as_deref());
977
  let source = res.storage.images.load(&doc.source)?;
978
  let inpainted = doc
979
  .inpainted
 
997
  text_block_index,
998
  shader_effect: shader_effect.unwrap_or_default(),
999
  shader_stroke,
1000
+ document_font,
1001
  bubbles: &bubbles,
1002
  image_width: doc.width,
1003
  image_height: doc.height,
koharu-app/src/google_fonts.rs ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::collections::HashMap;
2
+
3
+ use anyhow::{Context, Result};
4
+ use camino::{Utf8Path, Utf8PathBuf};
5
+ use koharu_core::{GoogleFontCatalog, GoogleFontEntry};
6
+ use tokio::sync::Mutex;
7
+ use tracing::debug;
8
+
9
+ const CATALOG_JSON: &str = include_str!("../data/google-fonts-catalog.json");
10
+
11
+ const RECOMMENDED_FAMILIES: &[&str] = &[
12
+ "Comic Neue",
13
+ "Bangers",
14
+ "Patrick Hand",
15
+ "Caveat",
16
+ "Pangolin",
17
+ ];
18
+
19
+ /// On-demand Google Fonts service with persistent disk caching.
20
+ pub struct GoogleFontService {
21
+ catalog: GoogleFontCatalog,
22
+ cache_dir: Utf8PathBuf,
23
+ /// Tracks which families have been downloaded to disk.
24
+ cached_families: Mutex<HashMap<String, Vec<Utf8PathBuf>>>,
25
+ }
26
+
27
+ impl GoogleFontService {
28
+ pub fn new(app_data_root: &Utf8Path) -> Result<Self> {
29
+ let catalog: GoogleFontCatalog =
30
+ serde_json::from_str(CATALOG_JSON).context("failed to parse Google Fonts catalog")?;
31
+ let cache_dir = app_data_root.join("fonts").join("google");
32
+ std::fs::create_dir_all(cache_dir.as_std_path())
33
+ .context("failed to create Google Fonts cache dir")?;
34
+
35
+ // Scan existing cache to populate known cached families
36
+ let mut cached_families = HashMap::new();
37
+ for entry in &catalog.fonts {
38
+ let family_dir = cache_dir.join(normalize_family_dir(&entry.family));
39
+ if family_dir.exists() {
40
+ let paths: Vec<Utf8PathBuf> = entry
41
+ .variants
42
+ .iter()
43
+ .map(|v| family_dir.join(&v.filename))
44
+ .filter(|p| p.exists())
45
+ .collect();
46
+ if !paths.is_empty() {
47
+ cached_families.insert(entry.family.clone(), paths);
48
+ }
49
+ }
50
+ }
51
+
52
+ Ok(Self {
53
+ catalog,
54
+ cache_dir,
55
+ cached_families: Mutex::new(cached_families),
56
+ })
57
+ }
58
+
59
+ /// Returns the full catalog for browsing.
60
+ pub fn catalog(&self) -> &GoogleFontCatalog {
61
+ &self.catalog
62
+ }
63
+
64
+ /// Returns the list of recommended font family names.
65
+ pub fn recommended_families(&self) -> &[&str] {
66
+ RECOMMENDED_FAMILIES
67
+ }
68
+
69
+ /// Checks if a family has been cached to disk.
70
+ pub async fn is_cached(&self, family: &str) -> bool {
71
+ self.cached_families.lock().await.contains_key(family)
72
+ }
73
+
74
+ /// Downloads a font family's regular variant to disk cache.
75
+ /// Returns the path to the cached .ttf file.
76
+ /// No-op if already cached.
77
+ pub async fn fetch_family(&self, family: &str, http: &reqwest_middleware::ClientWithMiddleware) -> Result<Utf8PathBuf> {
78
+ // Check cache first
79
+ {
80
+ let cached = self.cached_families.lock().await;
81
+ if let Some(first) = cached.get(family).and_then(|p| p.first()) {
82
+ return Ok(first.clone());
83
+ }
84
+ }
85
+
86
+ let entry = self
87
+ .catalog
88
+ .fonts
89
+ .iter()
90
+ .find(|e| e.family == family)
91
+ .with_context(|| format!("font family not found in catalog: {family}"))?;
92
+
93
+ // Prefer regular weight, fallback to first variant
94
+ let variant = entry
95
+ .variants
96
+ .iter()
97
+ .find(|v| v.weight == 400 && v.style == "normal")
98
+ .or_else(|| entry.variants.first())
99
+ .context("font has no variants")?;
100
+
101
+ let family_dir_name = normalize_family_dir(&entry.family);
102
+ let url = format!(
103
+ "https://raw.githubusercontent.com/google/fonts/main/ofl/{}/{}",
104
+ family_dir_name, variant.filename
105
+ );
106
+
107
+ debug!(%family, %url, "downloading Google Font");
108
+ let response = http
109
+ .get(&url)
110
+ .send()
111
+ .await
112
+ .context("failed to fetch Google Font")?
113
+ .error_for_status()
114
+ .context("Google Fonts CDN returned an error")?;
115
+ let bytes = response
116
+ .bytes()
117
+ .await
118
+ .context("failed to read font bytes")?;
119
+
120
+ // Write to disk cache
121
+ let family_dir = self.cache_dir.join(&family_dir_name);
122
+ std::fs::create_dir_all(family_dir.as_std_path())?;
123
+ let file_path = family_dir.join(&variant.filename);
124
+ std::fs::write(file_path.as_std_path(), &bytes)
125
+ .with_context(|| format!("failed to write cached font to {file_path}"))?;
126
+
127
+ // Update in-memory cache tracking
128
+ self.cached_families
129
+ .lock()
130
+ .await
131
+ .insert(family.to_string(), vec![file_path.clone()]);
132
+
133
+ Ok(file_path)
134
+ }
135
+
136
+ /// Reads the cached font file bytes. Returns None if not cached.
137
+ pub fn read_cached_file(&self, family: &str) -> Result<Option<Vec<u8>>> {
138
+ let entry = self.catalog.fonts.iter().find(|e| e.family == family);
139
+ let Some(entry) = entry else {
140
+ return Ok(None);
141
+ };
142
+ let variant = entry
143
+ .variants
144
+ .iter()
145
+ .find(|v| v.weight == 400 && v.style == "normal")
146
+ .or_else(|| entry.variants.first());
147
+ let Some(variant) = variant else {
148
+ return Ok(None);
149
+ };
150
+ let file_path = self
151
+ .cache_dir
152
+ .join(normalize_family_dir(&entry.family))
153
+ .join(&variant.filename);
154
+ if !file_path.exists() {
155
+ return Ok(None);
156
+ }
157
+ let data = std::fs::read(file_path.as_std_path()).context("failed to read cached font")?;
158
+ Ok(Some(data))
159
+ }
160
+
161
+ /// Find catalog entry by family name.
162
+ pub fn find_entry(&self, family: &str) -> Option<&GoogleFontEntry> {
163
+ self.catalog.fonts.iter().find(|e| e.family == family)
164
+ }
165
+ }
166
+
167
+ /// Converts family name to directory name (lowercase, spaces to empty).
168
+ /// e.g. "Comic Neue" -> "comicneue"
169
+ fn normalize_family_dir(family: &str) -> String {
170
+ family.to_lowercase().replace(' ', "")
171
+ }
koharu-app/src/lib.rs CHANGED
@@ -1,6 +1,7 @@
1
  pub mod config;
2
  pub mod edit;
3
  pub mod engine;
 
4
  pub mod io;
5
  pub mod llm;
6
  pub mod pipeline;
 
1
  pub mod config;
2
  pub mod edit;
3
  pub mod engine;
4
+ pub mod google_fonts;
5
  pub mod io;
6
  pub mod llm;
7
  pub mod pipeline;
koharu-app/src/renderer.rs CHANGED
@@ -1,9 +1,10 @@
1
  use std::sync::{Arc, Mutex};
2
 
3
- use anyhow::Result;
4
  use image::{DynamicImage, imageops};
5
  use koharu_core::{
6
- BlobRef, FontFaceInfo, TextAlign, TextBlock, TextShaderEffect, TextStrokeStyle, TextStyle,
 
7
  };
8
 
9
  use koharu_renderer::{
@@ -19,6 +20,7 @@ use koharu_renderer::{
19
  },
20
  };
21
 
 
22
  use crate::storage;
23
 
24
  /// Grouped options for [`Renderer::render_to_blob`] to keep the arg count manageable.
@@ -26,7 +28,7 @@ pub struct RenderTextOptions<'a> {
26
  pub text_block_index: Option<usize>,
27
  pub shader_effect: TextShaderEffect,
28
  pub shader_stroke: Option<TextStrokeStyle>,
29
- pub font_family: Option<&'a str>,
30
  pub bubbles: &'a [koharu_core::BubbleRegion],
31
  pub image_width: u32,
32
  pub image_height: u32,
@@ -135,16 +137,23 @@ pub struct Renderer {
135
  fontbook: Arc<Mutex<FontBook>>,
136
  renderer: TinySkiaRenderer,
137
  symbol_fallbacks: Vec<Font>,
 
138
  }
139
 
140
  impl Renderer {
141
  pub fn new() -> Result<Self> {
142
  let mut fontbook = FontBook::new();
143
  let symbol_fallbacks = load_symbol_fallbacks(&mut fontbook);
 
 
 
 
 
144
  Ok(Self {
145
  fontbook: Arc::new(Mutex::new(fontbook)),
146
  renderer: TinySkiaRenderer::new()?,
147
  symbol_fallbacks,
 
148
  })
149
  }
150
 
@@ -168,12 +177,30 @@ impl Renderer {
168
  Some(FontFaceInfo {
169
  family_name,
170
  post_script_name: face.post_script_name,
 
 
 
171
  })
172
  } else {
173
  None
174
  }
175
  })
176
  .collect::<Vec<_>>();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  fonts.sort();
178
  Ok(fonts)
179
  }
@@ -229,7 +256,7 @@ impl Renderer {
229
  text_block,
230
  opts.shader_effect,
231
  opts.shader_stroke.clone(),
232
- opts.font_family,
233
  bubble_area,
234
  base_font_size,
235
  min_font,
@@ -285,7 +312,7 @@ impl Renderer {
285
  text_block: &mut TextBlock,
286
  effect: TextShaderEffect,
287
  global_stroke: Option<TextStrokeStyle>,
288
- font_family: Option<&str>,
289
  bubble_area: Option<LayoutBox>,
290
  base_font_size: Option<f32>,
291
  min_font_size: f32,
@@ -317,7 +344,12 @@ impl Renderer {
317
  text_align: None,
318
  });
319
 
320
- apply_global_font_family(&mut style.font_families, font_family);
 
 
 
 
 
321
  apply_default_font_families(&mut style.font_families, &normalized_translation);
322
  let font = {
323
  let _s = tracing::info_span!("select_font").entered();
@@ -420,7 +452,7 @@ impl Renderer {
420
  text_block.render_height = None;
421
  }
422
  // rendered field will be set by the caller with a BlobRef
423
- let persisted_style = text_block.style.get_or_insert_with(|| TextStyle {
424
  font_families: Vec::new(),
425
  font_size: None,
426
  color,
@@ -428,7 +460,7 @@ impl Renderer {
428
  stroke: None,
429
  text_align: None,
430
  });
431
- persisted_style.font_families = vec![font.post_script_name().to_string()];
432
  Ok(Some(DynamicImage::ImageRgba8(rendered)))
433
  }
434
 
@@ -437,15 +469,29 @@ impl Renderer {
437
  .fontbook
438
  .lock()
439
  .map_err(|_| anyhow::anyhow!("Failed to lock fontbook"))?;
440
- let faces = fontbook.all_families();
441
- let post_script_name = style
442
- .font_families
443
- .iter()
444
- .find_map(|candidate| face_post_script_name(&faces, candidate))
445
- .ok_or_else(|| {
446
- anyhow::anyhow!("no font found for candidates: {:?}", style.font_families)
447
- })?;
448
- fontbook.query(&post_script_name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
449
  }
450
  }
451
 
@@ -453,14 +499,6 @@ fn default_stroke_width(font_size: f32) -> f32 {
453
  (font_size * 0.10).clamp(1.2, 8.0)
454
  }
455
 
456
- fn apply_global_font_family(font_families: &mut Vec<String>, font_family: Option<&str>) {
457
- if font_families.is_empty()
458
- && let Some(font_family) = font_family
459
- {
460
- font_families.push(font_family.to_string());
461
- }
462
- }
463
-
464
  fn apply_default_font_families(font_families: &mut Vec<String>, text: &str) {
465
  if font_families.is_empty() {
466
  *font_families = font_families_for_text(text);
@@ -705,8 +743,8 @@ fn find_best_bubble(block: &TextBlock, bubbles: &[koharu_core::BubbleRegion]) ->
705
  #[cfg(test)]
706
  mod tests {
707
  use super::{
708
- align_layout_horizontally, apply_default_font_families, apply_global_font_family,
709
- center_layout_vertically, resolve_stroke_style,
710
  };
711
  use koharu_core::{FontPrediction, TextAlign, TextBlock, TextStrokeStyle};
712
  use koharu_renderer::layout::{LayoutLine, LayoutRun, WritingMode};
@@ -815,21 +853,6 @@ mod tests {
815
  assert_eq!(layout.height, 60.0);
816
  }
817
 
818
- #[test]
819
- fn explicit_block_font_should_not_be_overridden_by_global_font() {
820
- let mut font_families = vec!["Block Font".to_string()];
821
- apply_global_font_family(&mut font_families, Some("Global Font"));
822
-
823
- assert_eq!(font_families, vec!["Block Font".to_string()]);
824
- }
825
-
826
- #[test]
827
- fn global_font_should_fill_empty_block_font_list() {
828
- let mut font_families = Vec::new();
829
- apply_global_font_family(&mut font_families, Some("Global Font"));
830
- assert_eq!(font_families, vec!["Global Font".to_string()]);
831
- }
832
-
833
  #[test]
834
  fn default_font_families_should_fill_empty_list() {
835
  let mut font_families = Vec::new();
@@ -837,15 +860,6 @@ mod tests {
837
  assert!(!font_families.is_empty());
838
  }
839
 
840
- #[test]
841
- fn global_font_should_be_applied_before_default_script_fonts() {
842
- let mut font_families = Vec::new();
843
- apply_global_font_family(&mut font_families, Some("Global Font"));
844
- apply_default_font_families(&mut font_families, "hello");
845
-
846
- assert_eq!(font_families, vec!["Global Font".to_string()]);
847
- }
848
-
849
  #[test]
850
  fn default_stroke_color_uses_black_for_light_text() {
851
  let stroke = resolve_stroke_style(
 
1
  use std::sync::{Arc, Mutex};
2
 
3
+ use anyhow::{Context, Result};
4
  use image::{DynamicImage, imageops};
5
  use koharu_core::{
6
+ BlobRef, FontFaceInfo, FontSource, TextAlign, TextBlock, TextShaderEffect, TextStrokeStyle,
7
+ TextStyle,
8
  };
9
 
10
  use koharu_renderer::{
 
20
  },
21
  };
22
 
23
+ use crate::google_fonts::GoogleFontService;
24
  use crate::storage;
25
 
26
  /// Grouped options for [`Renderer::render_to_blob`] to keep the arg count manageable.
 
28
  pub text_block_index: Option<usize>,
29
  pub shader_effect: TextShaderEffect,
30
  pub shader_stroke: Option<TextStrokeStyle>,
31
+ pub document_font: Option<&'a str>,
32
  pub bubbles: &'a [koharu_core::BubbleRegion],
33
  pub image_width: u32,
34
  pub image_height: u32,
 
137
  fontbook: Arc<Mutex<FontBook>>,
138
  renderer: TinySkiaRenderer,
139
  symbol_fallbacks: Vec<Font>,
140
+ pub google_fonts: Arc<GoogleFontService>,
141
  }
142
 
143
  impl Renderer {
144
  pub fn new() -> Result<Self> {
145
  let mut fontbook = FontBook::new();
146
  let symbol_fallbacks = load_symbol_fallbacks(&mut fontbook);
147
+ let app_data_root = koharu_runtime::default_app_data_root();
148
+ let google_fonts = Arc::new(
149
+ GoogleFontService::new(&app_data_root)
150
+ .context("failed to initialize Google Fonts service")?,
151
+ );
152
  Ok(Self {
153
  fontbook: Arc::new(Mutex::new(fontbook)),
154
  renderer: TinySkiaRenderer::new()?,
155
  symbol_fallbacks,
156
+ google_fonts,
157
  })
158
  }
159
 
 
177
  Some(FontFaceInfo {
178
  family_name,
179
  post_script_name: face.post_script_name,
180
+ source: FontSource::System,
181
+ category: None,
182
+ cached: true,
183
  })
184
  } else {
185
  None
186
  }
187
  })
188
  .collect::<Vec<_>>();
189
+
190
+ // Google Fonts (from catalog)
191
+ let catalog = self.google_fonts.catalog();
192
+ for entry in &catalog.fonts {
193
+ if seen.insert(entry.family.clone()) {
194
+ fonts.push(FontFaceInfo {
195
+ family_name: entry.family.clone(),
196
+ post_script_name: entry.family.clone(),
197
+ source: FontSource::Google,
198
+ category: Some(entry.category.clone()),
199
+ cached: false,
200
+ });
201
+ }
202
+ }
203
+
204
  fonts.sort();
205
  Ok(fonts)
206
  }
 
256
  text_block,
257
  opts.shader_effect,
258
  opts.shader_stroke.clone(),
259
+ opts.document_font,
260
  bubble_area,
261
  base_font_size,
262
  min_font,
 
312
  text_block: &mut TextBlock,
313
  effect: TextShaderEffect,
314
  global_stroke: Option<TextStrokeStyle>,
315
+ document_font: Option<&str>,
316
  bubble_area: Option<LayoutBox>,
317
  base_font_size: Option<f32>,
318
  min_font_size: f32,
 
344
  text_align: None,
345
  });
346
 
347
+ // Font cascade: per-block → document default → script fallback
348
+ if style.font_families.is_empty()
349
+ && let Some(font) = document_font
350
+ {
351
+ style.font_families.push(font.to_string());
352
+ }
353
  apply_default_font_families(&mut style.font_families, &normalized_translation);
354
  let font = {
355
  let _s = tracing::info_span!("select_font").entered();
 
452
  text_block.render_height = None;
453
  }
454
  // rendered field will be set by the caller with a BlobRef
455
+ let _persisted_style = text_block.style.get_or_insert_with(|| TextStyle {
456
  font_families: Vec::new(),
457
  font_size: None,
458
  color,
 
460
  stroke: None,
461
  text_align: None,
462
  });
463
+ // font_families is NOT set here — it only contains user-explicit choices
464
  Ok(Some(DynamicImage::ImageRgba8(rendered)))
465
  }
466
 
 
469
  .fontbook
470
  .lock()
471
  .map_err(|_| anyhow::anyhow!("Failed to lock fontbook"))?;
472
+
473
+ // Try each candidate through the full resolution chain before moving
474
+ // to the next. This ensures a global font (first in the list) is
475
+ // resolved from the disk cache even when a previously-loaded font
476
+ // appears later in the list.
477
+ for candidate in &style.font_families {
478
+ // Already loaded in FontBook (system font or previously loaded Google Font)?
479
+ let faces = fontbook.all_families();
480
+ if let Some(psn) = face_post_script_name(&faces, candidate) {
481
+ return fontbook.query(&psn);
482
+ }
483
+
484
+ // Try Google Fonts disk cache
485
+ if let Some(data) = self.google_fonts.read_cached_file(candidate)? {
486
+ let font = fontbook.load_from_bytes(data)?;
487
+ return Ok(font);
488
+ }
489
+ }
490
+
491
+ Err(anyhow::anyhow!(
492
+ "no font found for candidates: {:?}",
493
+ style.font_families
494
+ ))
495
  }
496
  }
497
 
 
499
  (font_size * 0.10).clamp(1.2, 8.0)
500
  }
501
 
 
 
 
 
 
 
 
 
502
  fn apply_default_font_families(font_families: &mut Vec<String>, text: &str) {
503
  if font_families.is_empty() {
504
  *font_families = font_families_for_text(text);
 
743
  #[cfg(test)]
744
  mod tests {
745
  use super::{
746
+ align_layout_horizontally, apply_default_font_families, center_layout_vertically,
747
+ resolve_stroke_style,
748
  };
749
  use koharu_core::{FontPrediction, TextAlign, TextBlock, TextStrokeStyle};
750
  use koharu_renderer::layout::{LayoutLine, LayoutRun, WritingMode};
 
853
  assert_eq!(layout.height, 60.0);
854
  }
855
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
856
  #[test]
857
  fn default_font_families_should_fill_empty_list() {
858
  let mut font_families = Vec::new();
 
860
  assert!(!font_families.is_empty());
861
  }
862
 
 
 
 
 
 
 
 
 
 
863
  #[test]
864
  fn default_stroke_color_uses_black_for_light_text() {
865
  let stroke = resolve_stroke_style(
koharu-core/src/commands.rs CHANGED
@@ -74,7 +74,6 @@ pub struct ProcessRequest {
74
  pub language: Option<String>,
75
  pub shader_effect: Option<TextShaderEffect>,
76
  pub shader_stroke: Option<TextStrokeStyle>,
77
- pub font_family: Option<String>,
78
  }
79
 
80
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -112,7 +111,6 @@ pub struct RenderParams {
112
  pub document_id: String,
113
  pub text_block_index: Option<usize>,
114
  pub shader_effect: Option<String>,
115
- pub font_family: Option<String>,
116
  }
117
 
118
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -128,7 +126,6 @@ pub struct ProcessParams {
128
  pub llm_target: Option<crate::LlmTarget>,
129
  pub language: Option<String>,
130
  pub shader_effect: Option<String>,
131
- pub font_family: Option<String>,
132
  }
133
 
134
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -283,7 +280,6 @@ mod tests {
283
  color: [255, 255, 255, 255],
284
  width_px: Some(2.0),
285
  }),
286
- font_family: Some("Noto Sans".to_string()),
287
  });
288
  round_trip(&ViewImageParams {
289
  document_id: "abc123".to_string(),
@@ -306,7 +302,6 @@ mod tests {
306
  document_id: "abc123".to_string(),
307
  text_block_index: Some(0),
308
  shader_effect: Some("bold".to_string()),
309
- font_family: Some("Noto Sans".to_string()),
310
  });
311
  round_trip(&ProcessParams {
312
  document_id: Some("abc123".to_string()),
@@ -317,7 +312,6 @@ mod tests {
317
  }),
318
  language: Some("zh-CN".to_string()),
319
  shader_effect: Some("italic,bold".to_string()),
320
- font_family: Some("Noto Sans".to_string()),
321
  });
322
  round_trip(&UpdateTextBlockPayload {
323
  index: 1,
 
74
  pub language: Option<String>,
75
  pub shader_effect: Option<TextShaderEffect>,
76
  pub shader_stroke: Option<TextStrokeStyle>,
 
77
  }
78
 
79
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
 
111
  pub document_id: String,
112
  pub text_block_index: Option<usize>,
113
  pub shader_effect: Option<String>,
 
114
  }
115
 
116
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
 
126
  pub llm_target: Option<crate::LlmTarget>,
127
  pub language: Option<String>,
128
  pub shader_effect: Option<String>,
 
129
  }
130
 
131
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
 
280
  color: [255, 255, 255, 255],
281
  width_px: Some(2.0),
282
  }),
 
283
  });
284
  round_trip(&ViewImageParams {
285
  document_id: "abc123".to_string(),
 
302
  document_id: "abc123".to_string(),
303
  text_block_index: Some(0),
304
  shader_effect: Some("bold".to_string()),
 
305
  });
306
  round_trip(&ProcessParams {
307
  document_id: Some("abc123".to_string()),
 
312
  }),
313
  language: Some("zh-CN".to_string()),
314
  shader_effect: Some("italic,bold".to_string()),
 
315
  });
316
  round_trip(&UpdateTextBlockPayload {
317
  index: 1,
koharu-core/src/google_fonts.rs ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use schemars::JsonSchema;
2
+ use serde::{Deserialize, Serialize};
3
+ use utoipa::ToSchema;
4
+
5
+ #[derive(Debug, Clone, Serialize, Deserialize)]
6
+ #[serde(rename_all = "camelCase")]
7
+ pub struct GoogleFontVariant {
8
+ pub style: String,
9
+ pub weight: u16,
10
+ pub filename: String,
11
+ }
12
+
13
+ #[derive(Debug, Clone, Serialize, Deserialize)]
14
+ #[serde(rename_all = "camelCase")]
15
+ pub struct GoogleFontEntry {
16
+ pub family: String,
17
+ pub category: String,
18
+ pub subsets: Vec<String>,
19
+ pub variants: Vec<GoogleFontVariant>,
20
+ }
21
+
22
+ #[derive(Debug, Clone, Serialize, Deserialize)]
23
+ pub struct GoogleFontCatalog {
24
+ pub fonts: Vec<GoogleFontEntry>,
25
+ }
26
+
27
+ #[derive(
28
+ Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, JsonSchema, ToSchema,
29
+ )]
30
+ #[serde(rename_all = "snake_case")]
31
+ pub enum FontSource {
32
+ System,
33
+ Google,
34
+ }
koharu-core/src/lib.rs CHANGED
@@ -1,5 +1,6 @@
1
  pub mod commands;
2
  pub mod events;
 
3
  pub mod parse;
4
  pub mod protocol;
5
  pub mod views;
@@ -12,6 +13,7 @@ pub use commands::*;
12
  pub use effect::TextShaderEffect;
13
  pub use events::*;
14
  pub use font::{FontPrediction, NamedFontPrediction, TextDirection, TopFont};
 
15
  pub use image::SerializableDynamicImage;
16
  pub use protocol::*;
17
 
@@ -175,6 +177,13 @@ pub struct BubbleRegion {
175
  pub confidence: f32,
176
  }
177
 
 
 
 
 
 
 
 
178
  #[derive(Default, Debug, Clone, Serialize, Deserialize)]
179
  #[serde(rename_all = "camelCase")]
180
  pub struct Document {
@@ -190,6 +199,8 @@ pub struct Document {
190
  pub text_blocks: Vec<TextBlock>,
191
  #[serde(default)]
192
  pub bubbles: Vec<BubbleRegion>,
 
 
193
  }
194
  #[cfg(test)]
195
  mod tests {}
 
1
  pub mod commands;
2
  pub mod events;
3
+ pub mod google_fonts;
4
  pub mod parse;
5
  pub mod protocol;
6
  pub mod views;
 
13
  pub use effect::TextShaderEffect;
14
  pub use events::*;
15
  pub use font::{FontPrediction, NamedFontPrediction, TextDirection, TopFont};
16
+ pub use google_fonts::{FontSource, GoogleFontCatalog, GoogleFontEntry, GoogleFontVariant};
17
  pub use image::SerializableDynamicImage;
18
  pub use protocol::*;
19
 
 
177
  pub confidence: f32,
178
  }
179
 
180
+ #[derive(Default, Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
181
+ #[serde(rename_all = "camelCase")]
182
+ pub struct DocumentStyle {
183
+ #[serde(default, skip_serializing_if = "Option::is_none")]
184
+ pub default_font: Option<String>,
185
+ }
186
+
187
  #[derive(Default, Debug, Clone, Serialize, Deserialize)]
188
  #[serde(rename_all = "camelCase")]
189
  pub struct Document {
 
199
  pub text_blocks: Vec<TextBlock>,
200
  #[serde(default)]
201
  pub bubbles: Vec<BubbleRegion>,
202
+ #[serde(default)]
203
+ pub style: Option<DocumentStyle>,
204
  }
205
  #[cfg(test)]
206
  mod tests {}
koharu-core/src/protocol.rs CHANGED
@@ -11,6 +11,10 @@ use crate::{FontPrediction, TextBlock, TextShaderEffect, TextStrokeStyle, TextSt
11
  pub struct FontFaceInfo {
12
  pub family_name: String,
13
  pub post_script_name: String,
 
 
 
 
14
  }
15
 
16
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
@@ -119,6 +123,8 @@ pub struct DocumentDetail {
119
  /// Blob hash for the rendered composite layer.
120
  #[serde(skip_serializing_if = "Option::is_none")]
121
  pub rendered: Option<String>,
 
 
122
  }
123
 
124
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
@@ -339,7 +345,6 @@ pub struct RenderRequest {
339
  pub text_block_id: Option<String>,
340
  pub shader_effect: Option<TextShaderEffect>,
341
  pub shader_stroke: Option<TextStrokeStyle>,
342
- pub font_family: Option<String>,
343
  }
344
 
345
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
@@ -365,7 +370,6 @@ pub struct PipelineJobRequest {
365
  pub language: Option<String>,
366
  pub shader_effect: Option<TextShaderEffect>,
367
  pub shader_stroke: Option<TextStrokeStyle>,
368
- pub font_family: Option<String>,
369
  }
370
 
371
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
 
11
  pub struct FontFaceInfo {
12
  pub family_name: String,
13
  pub post_script_name: String,
14
+ pub source: crate::google_fonts::FontSource,
15
+ #[serde(skip_serializing_if = "Option::is_none")]
16
+ pub category: Option<String>,
17
+ pub cached: bool,
18
  }
19
 
20
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
 
123
  /// Blob hash for the rendered composite layer.
124
  #[serde(skip_serializing_if = "Option::is_none")]
125
  pub rendered: Option<String>,
126
+ #[serde(skip_serializing_if = "Option::is_none")]
127
+ pub style: Option<crate::DocumentStyle>,
128
  }
129
 
130
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
 
345
  pub text_block_id: Option<String>,
346
  pub shader_effect: Option<TextShaderEffect>,
347
  pub shader_stroke: Option<TextStrokeStyle>,
 
348
  }
349
 
350
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
 
370
  pub language: Option<String>,
371
  pub shader_effect: Option<TextShaderEffect>,
372
  pub shader_stroke: Option<TextStrokeStyle>,
 
373
  }
374
 
375
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
koharu-renderer/src/font.rs CHANGED
@@ -124,6 +124,19 @@ impl FontBook {
124
  self.load_font(id)
125
  }
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  fn load_font(&mut self, id: ID) -> anyhow::Result<Font> {
128
  if let Some(font) = self.cache.get(&id) {
129
  return Ok(font.clone());
 
124
  self.load_font(id)
125
  }
126
 
127
+ /// Loads a font from raw bytes (e.g., downloaded from Google Fonts).
128
+ /// Returns the loaded Font on success.
129
+ pub fn load_from_bytes(&mut self, data: Vec<u8>) -> anyhow::Result<Font> {
130
+ let data: Arc<dyn AsRef<[u8]> + Send + Sync> = Arc::new(data);
131
+ let source = fontdb::Source::Binary(data);
132
+ let ids = self.database.load_font_source(source);
133
+ let id = ids
134
+ .into_iter()
135
+ .next()
136
+ .context("font data contained no valid faces")?;
137
+ self.load_font(id)
138
+ }
139
+
140
  fn load_font(&mut self, id: ID) -> anyhow::Result<Font> {
141
  if let Some(font) = self.cache.get(&id) {
142
  return Ok(font.clone());
koharu-renderer/src/text/script.rs CHANGED
@@ -73,12 +73,12 @@ pub fn font_families_for_text(text: &str) -> Vec<String> {
73
  &["Noto Sans"]
74
  }
75
  } else {
 
76
  #[cfg(target_os = "windows")]
77
  {
78
  &[
79
- "CC Wild Words",
80
- "Wild Words",
81
- "Anime Ace 2.0 BB",
82
  "Comic Sans MS",
83
  "Trebuchet MS",
84
  "Segoe UI",
@@ -88,9 +88,8 @@ pub fn font_families_for_text(text: &str) -> Vec<String> {
88
  #[cfg(target_os = "macos")]
89
  {
90
  &[
91
- "CC Wild Words",
92
- "Wild Words",
93
- "Anime Ace 2.0 BB",
94
  "Chalkboard SE",
95
  "Noteworthy",
96
  "SF Pro",
@@ -100,10 +99,8 @@ pub fn font_families_for_text(text: &str) -> Vec<String> {
100
  #[cfg(not(any(target_os = "windows", target_os = "macos")))]
101
  {
102
  &[
103
- "CC Wild Words",
104
- "Wild Words",
105
- "Anime Ace 2.0 BB",
106
  "Comic Neue",
 
107
  "Noto Sans",
108
  "DejaVu Sans",
109
  "Liberation Sans",
 
73
  &["Noto Sans"]
74
  }
75
  } else {
76
+ // Google Fonts candidates (downloaded on demand) + platform fallbacks
77
  #[cfg(target_os = "windows")]
78
  {
79
  &[
80
+ "Comic Neue",
81
+ "Bangers",
 
82
  "Comic Sans MS",
83
  "Trebuchet MS",
84
  "Segoe UI",
 
88
  #[cfg(target_os = "macos")]
89
  {
90
  &[
91
+ "Comic Neue",
92
+ "Bangers",
 
93
  "Chalkboard SE",
94
  "Noteworthy",
95
  "SF Pro",
 
99
  #[cfg(not(any(target_os = "windows", target_os = "macos")))]
100
  {
101
  &[
 
 
 
102
  "Comic Neue",
103
+ "Bangers",
104
  "Noto Sans",
105
  "DejaVu Sans",
106
  "Liberation Sans",
koharu-rpc/src/api.rs CHANGED
@@ -42,6 +42,7 @@ pub fn api() -> (axum::Router<ApiState>, utoipa::openapi::OpenApi) {
42
  OpenApiRouter::default()
43
  .routes(routes!(list_documents, import_documents))
44
  .routes(routes!(get_document))
 
45
  .routes(routes!(get_blob))
46
  .routes(routes!(get_document_thumbnail))
47
  .routes(routes!(detect_document))
@@ -64,6 +65,8 @@ pub fn api() -> (axum::Router<ApiState>, utoipa::openapi::OpenApi) {
64
  .routes(routes!(list_downloads))
65
  .routes(routes!(get_meta))
66
  .routes(routes!(list_fonts))
 
 
67
  .routes(routes!(get_config, update_config))
68
  .routes(routes!(get_engine_catalog))
69
  .split_for_parts()
@@ -254,6 +257,154 @@ async fn list_fonts(State(state): State<ApiState>) -> ApiResult<Json<Vec<FontFac
254
  Ok(Json(fonts))
255
  }
256
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  // ---------------------------------------------------------------------------
258
  // Documents
259
  // ---------------------------------------------------------------------------
@@ -274,6 +425,45 @@ async fn list_documents(State(state): State<ApiState>) -> ApiResult<Json<Vec<Doc
274
  Ok(Json(documents))
275
  }
276
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  #[utoipa::path(
278
  get,
279
  path = "/documents/{document_id}",
@@ -311,6 +501,7 @@ async fn get_document(
311
  inpainted: doc.inpainted.as_ref().map(|r| r.hash().to_string()),
312
  brush_layer: doc.brush_layer.as_ref().map(|r| r.hash().to_string()),
313
  rendered: doc.rendered.as_ref().map(|r| r.hash().to_string()),
 
314
  };
315
 
316
  Ok(Json(detail))
@@ -533,7 +724,6 @@ async fn render_document(
533
  text_block_index,
534
  request.shader_effect,
535
  request.shader_stroke,
536
- request.font_family.as_deref(),
537
  )
538
  .await?;
539
 
@@ -829,7 +1019,7 @@ async fn patch_text_block(
829
  .map_err(ApiError::from)?;
830
 
831
  if needs_render && let Some(idx) = block_index {
832
- engine::render_document(&resources, &document_id, Some(idx), None, None, None)
833
  .await
834
  .map_err(ApiError::internal)?;
835
  }
@@ -1001,7 +1191,6 @@ async fn start_pipeline(
1001
  language: request.language,
1002
  shader_effect: request.shader_effect,
1003
  shader_stroke: request.shader_stroke,
1004
- font_family: request.font_family,
1005
  },
1006
  state.tracker.jobs(),
1007
  )
 
42
  OpenApiRouter::default()
43
  .routes(routes!(list_documents, import_documents))
44
  .routes(routes!(get_document))
45
+ .routes(routes!(update_document_style))
46
  .routes(routes!(get_blob))
47
  .routes(routes!(get_document_thumbnail))
48
  .routes(routes!(detect_document))
 
65
  .routes(routes!(list_downloads))
66
  .routes(routes!(get_meta))
67
  .routes(routes!(list_fonts))
68
+ .routes(routes!(get_google_fonts_catalog))
69
+ .routes(routes!(fetch_google_font, get_google_font_file))
70
  .routes(routes!(get_config, update_config))
71
  .routes(routes!(get_engine_catalog))
72
  .split_for_parts()
 
257
  Ok(Json(fonts))
258
  }
259
 
260
+ // ---------------------------------------------------------------------------
261
+ // Google Fonts
262
+ // ---------------------------------------------------------------------------
263
+
264
+ #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
265
+ #[serde(rename_all = "camelCase")]
266
+ struct GoogleFontCatalogResponse {
267
+ fonts: Vec<GoogleFontCatalogEntry>,
268
+ recommended: Vec<String>,
269
+ }
270
+
271
+ #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
272
+ #[serde(rename_all = "camelCase")]
273
+ struct GoogleFontCatalogEntry {
274
+ family: String,
275
+ category: String,
276
+ subsets: Vec<String>,
277
+ cached: bool,
278
+ }
279
+
280
+ #[utoipa::path(
281
+ get,
282
+ path = "/fonts/google/catalog",
283
+ operation_id = "getGoogleFontsCatalog",
284
+ tag = "system",
285
+ responses(
286
+ (status = 200, body = GoogleFontCatalogResponse),
287
+ (status = 503, body = ApiError),
288
+ ),
289
+ )]
290
+ async fn get_google_fonts_catalog(
291
+ State(state): State<ApiState>,
292
+ ) -> ApiResult<Json<GoogleFontCatalogResponse>> {
293
+ let resources = state.resources()?;
294
+ let renderer = engine::get_renderer(&resources)
295
+ .await
296
+ .map_err(ApiError::from)?;
297
+ let service = &renderer.google_fonts;
298
+ let catalog = service.catalog();
299
+
300
+ let mut fonts = Vec::with_capacity(catalog.fonts.len());
301
+ for entry in &catalog.fonts {
302
+ fonts.push(GoogleFontCatalogEntry {
303
+ family: entry.family.clone(),
304
+ category: entry.category.clone(),
305
+ subsets: entry.subsets.clone(),
306
+ cached: service.is_cached(&entry.family).await,
307
+ });
308
+ }
309
+
310
+ let recommended = service
311
+ .recommended_families()
312
+ .iter()
313
+ .map(|s| s.to_string())
314
+ .collect();
315
+
316
+ Ok(Json(GoogleFontCatalogResponse { fonts, recommended }))
317
+ }
318
+
319
+ #[utoipa::path(
320
+ post,
321
+ path = "/fonts/google/{family}/fetch",
322
+ operation_id = "fetchGoogleFont",
323
+ tag = "system",
324
+ params(
325
+ ("family" = String, Path, description = "Font family name"),
326
+ ),
327
+ responses(
328
+ (status = 200, body = FontFaceInfo),
329
+ (status = 404, body = ApiError),
330
+ (status = 503, body = ApiError),
331
+ ),
332
+ )]
333
+ async fn fetch_google_font(
334
+ State(state): State<ApiState>,
335
+ Path(family): Path<String>,
336
+ ) -> ApiResult<Json<FontFaceInfo>> {
337
+ let resources = state.resources()?;
338
+ let renderer = engine::get_renderer(&resources)
339
+ .await
340
+ .map_err(ApiError::from)?;
341
+ let service = &renderer.google_fonts;
342
+
343
+ let entry = service.find_entry(&family).ok_or_else(|| {
344
+ ApiError::new(
345
+ StatusCode::NOT_FOUND,
346
+ format!("font family not found: {family}"),
347
+ )
348
+ })?;
349
+
350
+ let http = resources.runtime.http_client();
351
+ service
352
+ .fetch_family(&family, &http)
353
+ .await
354
+ .map_err(ApiError::from)?;
355
+
356
+ let category = Some(entry.category.clone());
357
+
358
+ Ok(Json(FontFaceInfo {
359
+ family_name: family.clone(),
360
+ post_script_name: family,
361
+ source: koharu_core::FontSource::Google,
362
+ category,
363
+ cached: true,
364
+ }))
365
+ }
366
+
367
+ #[utoipa::path(
368
+ get,
369
+ path = "/fonts/google/{family}/file",
370
+ operation_id = "getGoogleFontFile",
371
+ tag = "system",
372
+ params(
373
+ ("family" = String, Path, description = "Font family name"),
374
+ ),
375
+ responses(
376
+ (status = 200, description = "Font file bytes", content_type = "font/ttf"),
377
+ (status = 404, body = ApiError),
378
+ (status = 503, body = ApiError),
379
+ ),
380
+ )]
381
+ async fn get_google_font_file(
382
+ State(state): State<ApiState>,
383
+ Path(family): Path<String>,
384
+ ) -> Result<Response, ApiError> {
385
+ let resources = state.resources()?;
386
+ let renderer = engine::get_renderer(&resources)
387
+ .await
388
+ .map_err(ApiError::from)?;
389
+ let service = &renderer.google_fonts;
390
+
391
+ let data = service
392
+ .read_cached_file(&family)
393
+ .map_err(ApiError::from)?
394
+ .ok_or_else(|| {
395
+ ApiError::new(
396
+ StatusCode::NOT_FOUND,
397
+ format!("font not cached: {family}. Call fetch first."),
398
+ )
399
+ })?;
400
+
401
+ Ok(Response::builder()
402
+ .status(StatusCode::OK)
403
+ .header(CONTENT_TYPE, HeaderValue::from_static("font/ttf"))
404
+ .body(Body::from(data))
405
+ .unwrap())
406
+ }
407
+
408
  // ---------------------------------------------------------------------------
409
  // Documents
410
  // ---------------------------------------------------------------------------
 
425
  Ok(Json(documents))
426
  }
427
 
428
+ #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
429
+ #[serde(rename_all = "camelCase")]
430
+ struct UpdateDocumentStyleRequest {
431
+ #[serde(default)]
432
+ pub default_font: Option<String>,
433
+ }
434
+
435
+ #[utoipa::path(
436
+ patch,
437
+ path = "/documents/{document_id}/style",
438
+ operation_id = "updateDocumentStyle",
439
+ tag = "documents",
440
+ params(
441
+ ("document_id" = String, Path, description = "Document ID"),
442
+ ),
443
+ request_body = UpdateDocumentStyleRequest,
444
+ responses(
445
+ (status = 200, body = UpdateDocumentStyleRequest),
446
+ (status = 404, body = ApiError),
447
+ (status = 503, body = ApiError),
448
+ ),
449
+ )]
450
+ async fn update_document_style(
451
+ State(state): State<ApiState>,
452
+ Path(document_id): Path<String>,
453
+ Json(request): Json<UpdateDocumentStyleRequest>,
454
+ ) -> ApiResult<Json<UpdateDocumentStyleRequest>> {
455
+ let resources = state.resources()?;
456
+ resources
457
+ .storage
458
+ .update_page(&document_id, |doc| {
459
+ let style = doc.style.get_or_insert_with(Default::default);
460
+ style.default_font = request.default_font.clone();
461
+ })
462
+ .await
463
+ .map_err(ApiError::from)?;
464
+ Ok(Json(request))
465
+ }
466
+
467
  #[utoipa::path(
468
  get,
469
  path = "/documents/{document_id}",
 
501
  inpainted: doc.inpainted.as_ref().map(|r| r.hash().to_string()),
502
  brush_layer: doc.brush_layer.as_ref().map(|r| r.hash().to_string()),
503
  rendered: doc.rendered.as_ref().map(|r| r.hash().to_string()),
504
+ style: doc.style.clone(),
505
  };
506
 
507
  Ok(Json(detail))
 
724
  text_block_index,
725
  request.shader_effect,
726
  request.shader_stroke,
 
727
  )
728
  .await?;
729
 
 
1019
  .map_err(ApiError::from)?;
1020
 
1021
  if needs_render && let Some(idx) = block_index {
1022
+ engine::render_document(&resources, &document_id, Some(idx), None, None)
1023
  .await
1024
  .map_err(ApiError::internal)?;
1025
  }
 
1191
  language: request.language,
1192
  shader_effect: request.shader_effect,
1193
  shader_stroke: request.shader_stroke,
 
1194
  },
1195
  state.tracker.jobs(),
1196
  )
koharu-rpc/src/mcp/mod.rs CHANGED
@@ -378,16 +378,9 @@ impl KoharuMcp {
378
  .transpose()
379
  .map_err(|e: anyhow::Error| e.to_string())?;
380
 
381
- engine::render_document(
382
- &res,
383
- &p.document_id,
384
- p.text_block_index,
385
- effect,
386
- None,
387
- p.font_family.as_deref(),
388
- )
389
- .await
390
- .map_err(|e| e.to_string())?;
391
 
392
  Ok("Render complete".to_string())
393
  }
@@ -482,7 +475,6 @@ impl KoharuMcp {
482
  language: p.language,
483
  shader_effect: effect,
484
  shader_stroke: None,
485
- font_family: p.font_family,
486
  },
487
  jobs,
488
  )
 
378
  .transpose()
379
  .map_err(|e: anyhow::Error| e.to_string())?;
380
 
381
+ engine::render_document(&res, &p.document_id, p.text_block_index, effect, None)
382
+ .await
383
+ .map_err(|e| e.to_string())?;
 
 
 
 
 
 
 
384
 
385
  Ok("Render complete".to_string())
386
  }
 
475
  language: p.language,
476
  shader_effect: effect,
477
  shader_stroke: None,
 
478
  },
479
  jobs,
480
  )
ui/components/MenuBar.tsx CHANGED
@@ -40,7 +40,6 @@ import {
40
  import { SettingsDialog, type TabId } from '@/components/SettingsDialog'
41
  import { useProcessing } from '@/lib/machines'
42
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
43
- import { usePreferencesStore } from '@/lib/stores/preferencesStore'
44
  import type { PipelineJobRequest } from '@/lib/api/schemas'
45
 
46
  type MenuItem = {
@@ -69,14 +68,12 @@ export function MenuBar() {
69
  const buildPipelineRequest = (documentId?: string): PipelineJobRequest => {
70
  const { selectedTarget, selectedLanguage, renderEffect, renderStroke } =
71
  useEditorUiStore.getState()
72
- const { fontFamily } = usePreferencesStore.getState()
73
  return {
74
  documentId,
75
  llm: selectedTarget ? { target: selectedTarget } : undefined,
76
  language: selectedLanguage,
77
  shaderEffect: renderEffect,
78
  shaderStroke: renderStroke,
79
- fontFamily,
80
  }
81
  }
82
 
 
40
  import { SettingsDialog, type TabId } from '@/components/SettingsDialog'
41
  import { useProcessing } from '@/lib/machines'
42
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
 
43
  import type { PipelineJobRequest } from '@/lib/api/schemas'
44
 
45
  type MenuItem = {
 
68
  const buildPipelineRequest = (documentId?: string): PipelineJobRequest => {
69
  const { selectedTarget, selectedLanguage, renderEffect, renderStroke } =
70
  useEditorUiStore.getState()
 
71
  return {
72
  documentId,
73
  llm: selectedTarget ? { target: selectedTarget } : undefined,
74
  language: selectedLanguage,
75
  shaderEffect: renderEffect,
76
  shaderStroke: renderStroke,
 
77
  }
78
  }
79
 
ui/components/SettingsDialog.tsx CHANGED
@@ -56,7 +56,6 @@ import {
56
  updateConfig,
57
  } from '@/lib/api/system/system'
58
  import { getLlmCatalog, getGetLlmCatalogQueryKey } from '@/lib/api/llm/llm'
59
- import { usePreferencesStore } from '@/lib/stores/preferencesStore'
60
  import { supportedLanguages } from '@/lib/i18n'
61
  import type {
62
  UpdateConfigBody,
@@ -405,9 +404,6 @@ function AppearancePane() {
405
  const { t, i18n } = useTranslation()
406
  const { theme, setTheme } = useTheme()
407
  const locales = useMemo(() => supportedLanguages, [])
408
- const fontFamily = usePreferencesStore((s) => s.fontFamily)
409
- const setFontFamily = usePreferencesStore((s) => s.setFontFamily)
410
-
411
  return (
412
  <div className='space-y-8'>
413
  <Section title={t('settings.theme')}>
@@ -443,18 +439,6 @@ function AppearancePane() {
443
  </SelectContent>
444
  </Select>
445
  </Section>
446
-
447
- <Section
448
- title={t('settings.renderingFont')}
449
- description={t('settings.renderingFontDescription')}
450
- >
451
- <Input
452
- type='text'
453
- value={fontFamily ?? ''}
454
- onChange={(e) => setFontFamily(e.target.value || undefined)}
455
- placeholder='e.g. Noto Sans CJK'
456
- />
457
- </Section>
458
  </div>
459
  )
460
  }
 
56
  updateConfig,
57
  } from '@/lib/api/system/system'
58
  import { getLlmCatalog, getGetLlmCatalogQueryKey } from '@/lib/api/llm/llm'
 
59
  import { supportedLanguages } from '@/lib/i18n'
60
  import type {
61
  UpdateConfigBody,
 
404
  const { t, i18n } = useTranslation()
405
  const { theme, setTheme } = useTheme()
406
  const locales = useMemo(() => supportedLanguages, [])
 
 
 
407
  return (
408
  <div className='space-y-8'>
409
  <Section title={t('settings.theme')}>
 
439
  </SelectContent>
440
  </Select>
441
  </Section>
 
 
 
 
 
 
 
 
 
 
 
 
442
  </div>
443
  )
444
  }
ui/components/canvas/CanvasToolbar.tsx CHANGED
@@ -38,7 +38,6 @@ import type {
38
  LlmProviderCatalog,
39
  } from '@/lib/api/schemas'
40
  import { useProcessing } from '@/lib/machines'
41
- import { usePreferencesStore } from '@/lib/stores/preferencesStore'
42
  import { llmTargetKey, sameLlmTarget } from '@/lib/llmTargets'
43
  import i18n from '@/lib/i18n'
44
 
@@ -194,14 +193,12 @@ function WorkflowButtons() {
194
  onClick={() => {
195
  const documentId = requireDocumentId()
196
  const { renderEffect, renderStroke } = useEditorUiStore.getState()
197
- const { fontFamily } = usePreferencesStore.getState()
198
  send({
199
  type: 'START_RENDER',
200
  documentId,
201
  options: {
202
  shaderEffect: renderEffect,
203
  shaderStroke: renderStroke,
204
- fontFamily,
205
  },
206
  })
207
  }}
 
38
  LlmProviderCatalog,
39
  } from '@/lib/api/schemas'
40
  import { useProcessing } from '@/lib/machines'
 
41
  import { llmTargetKey, sameLlmTarget } from '@/lib/llmTargets'
42
  import i18n from '@/lib/i18n'
43
 
 
193
  onClick={() => {
194
  const documentId = requireDocumentId()
195
  const { renderEffect, renderStroke } = useEditorUiStore.getState()
 
196
  send({
197
  type: 'START_RENDER',
198
  documentId,
199
  options: {
200
  shaderEffect: renderEffect,
201
  shaderStroke: renderStroke,
 
202
  },
203
  })
204
  }}
ui/components/panels/RenderControlsPanel.tsx CHANGED
@@ -1,6 +1,6 @@
1
  'use client'
2
 
3
- import type { ComponentType } from 'react'
4
  import { useTranslation } from 'react-i18next'
5
  import {
6
  AlignCenterIcon,
@@ -31,8 +31,8 @@ import {
31
  } from '@/components/ui/tooltip'
32
  import { FontSelect } from '@/components/ui/font-select'
33
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
34
- import { usePreferencesStore } from '@/lib/stores/preferencesStore'
35
- import { useListFonts } from '@/lib/api/system/system'
36
  import { cn } from '@/lib/utils'
37
 
38
  const DEFAULT_COLOR: RgbaColor = [0, 0, 0, 255]
@@ -40,6 +40,8 @@ const DEFAULT_FONT_FACES: FontFaceInfo[] = [
40
  {
41
  familyName: 'Arial',
42
  postScriptName: 'ArialMT',
 
 
43
  },
44
  ]
45
  const DEFAULT_EFFECT: RenderEffect = {
@@ -117,6 +119,8 @@ const fallbackFontFace = (value?: string): FontFaceInfo | undefined => {
117
  return {
118
  familyName: normalized,
119
  postScriptName: normalized,
 
 
120
  }
121
  }
122
 
@@ -177,10 +181,26 @@ export function RenderControlsPanel() {
177
  const setRenderEffect = useEditorUiStore((state) => state.setRenderEffect)
178
  const setRenderStroke = useEditorUiStore((state) => state.setRenderStroke)
179
  const { data: availableFonts = [] } = useListFonts()
180
- const fontFamily = usePreferencesStore((state) => state.fontFamily)
181
- const setFontFamily = usePreferencesStore((state) => state.setFontFamily)
182
- const { textBlocks, selectedBlockIndex, replaceBlock, updateTextBlocks } =
183
- useTextBlocks()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  const { t } = useTranslation()
185
  const selectedBlock =
186
  selectedBlockIndex !== undefined
@@ -190,8 +210,8 @@ export function RenderControlsPanel() {
190
  const hasBlocks = textBlocks.length > 0
191
  const fontCandidates = uniqueFontFaces(
192
  [
193
- ...availableFonts,
194
- ...(fontFamily ? [fallbackFontFace(fontFamily)] : []),
195
  ...(selectedBlock?.style?.fontFamilies
196
  ?.slice(0, 1)
197
  ?.map(fallbackFontFace) ?? []),
@@ -206,7 +226,7 @@ export function RenderControlsPanel() {
206
  const fontOptions = fontCandidates
207
  const currentFontCandidate =
208
  selectedBlock?.style?.fontFamilies?.[0] ??
209
- fontFamily ??
210
  firstBlock?.style?.fontFamilies?.[0] ??
211
  (hasBlocks ? fallbackFontFaces[0]?.postScriptName : '')
212
  const currentFontFace =
@@ -386,21 +406,23 @@ export function RenderControlsPanel() {
386
  selectedBlock?.style?.fontFamilies,
387
  )
388
  if (applyStyleToSelected({ fontFamilies: nextFamilies })) return
389
- setFontFamily(value)
390
- if (!hasBlocks) return
391
- const nextBlocks = textBlocks.map((block) => ({
392
- ...block,
393
- style: buildStyle(block, block.style, {
394
- fontFamilies: mergeFontFamilies(
395
- value,
396
- block.style?.fontFamilies,
397
- ),
398
- }),
399
- }))
400
- void updateTextBlocks(nextBlocks)
401
  }}
402
  />
403
  </div>
 
 
 
 
 
 
 
 
 
 
404
  <ColorPicker
405
  value={currentColorHex}
406
  disabled={!hasBlocks}
 
1
  'use client'
2
 
3
+ import { type ComponentType, useMemo } from 'react'
4
  import { useTranslation } from 'react-i18next'
5
  import {
6
  AlignCenterIcon,
 
31
  } from '@/components/ui/tooltip'
32
  import { FontSelect } from '@/components/ui/font-select'
33
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
34
+ import { useListFonts, useGetGoogleFontsCatalog } from '@/lib/api/system/system'
35
+ import { updateDocumentStyle } from '@/lib/api/documents/documents'
36
  import { cn } from '@/lib/utils'
37
 
38
  const DEFAULT_COLOR: RgbaColor = [0, 0, 0, 255]
 
40
  {
41
  familyName: 'Arial',
42
  postScriptName: 'ArialMT',
43
+ source: 'system',
44
+ cached: true,
45
  },
46
  ]
47
  const DEFAULT_EFFECT: RenderEffect = {
 
119
  return {
120
  familyName: normalized,
121
  postScriptName: normalized,
122
+ source: 'system',
123
+ cached: true,
124
  }
125
  }
126
 
 
181
  const setRenderEffect = useEditorUiStore((state) => state.setRenderEffect)
182
  const setRenderStroke = useEditorUiStore((state) => state.setRenderStroke)
183
  const { data: availableFonts = [] } = useListFonts()
184
+ const { data: catalog } = useGetGoogleFontsCatalog()
185
+ const sortedFonts = useMemo(() => {
186
+ if (!availableFonts) return []
187
+ const recommended = new Set(catalog?.recommended ?? [])
188
+ return [...availableFonts].sort((a, b) => {
189
+ const aRec = recommended.has(a.familyName) ? 0 : 1
190
+ const bRec = recommended.has(b.familyName) ? 0 : 1
191
+ if (aRec !== bRec) return aRec - bRec
192
+ return a.familyName.localeCompare(b.familyName)
193
+ })
194
+ }, [availableFonts, catalog])
195
+ const {
196
+ document: currentDocument,
197
+ textBlocks,
198
+ selectedBlockIndex,
199
+ replaceBlock,
200
+ updateTextBlocks,
201
+ } = useTextBlocks()
202
+ const documentId = currentDocument?.id
203
+ const documentFont = currentDocument?.style?.defaultFont ?? undefined
204
  const { t } = useTranslation()
205
  const selectedBlock =
206
  selectedBlockIndex !== undefined
 
210
  const hasBlocks = textBlocks.length > 0
211
  const fontCandidates = uniqueFontFaces(
212
  [
213
+ ...sortedFonts,
214
+ ...(documentFont ? [fallbackFontFace(documentFont)] : []),
215
  ...(selectedBlock?.style?.fontFamilies
216
  ?.slice(0, 1)
217
  ?.map(fallbackFontFace) ?? []),
 
226
  const fontOptions = fontCandidates
227
  const currentFontCandidate =
228
  selectedBlock?.style?.fontFamilies?.[0] ??
229
+ documentFont ??
230
  firstBlock?.style?.fontFamilies?.[0] ??
231
  (hasBlocks ? fallbackFontFaces[0]?.postScriptName : '')
232
  const currentFontFace =
 
406
  selectedBlock?.style?.fontFamilies,
407
  )
408
  if (applyStyleToSelected({ fontFamilies: nextFamilies })) return
409
+ // Set as document default font
410
+ if (documentId) {
411
+ void updateDocumentStyle(documentId, { defaultFont: value })
412
+ }
 
 
 
 
 
 
 
 
413
  }}
414
  />
415
  </div>
416
+ {selectedBlock?.style?.fontFamilies?.length ? (
417
+ <button
418
+ type='button'
419
+ className='text-muted-foreground hover:text-foreground text-[9px]'
420
+ onClick={() => applyStyleToSelected({ fontFamilies: [] })}
421
+ title='Reset to document default'
422
+ >
423
+
424
+ </button>
425
+ ) : null}
426
  <ColorPicker
427
  value={currentColorHex}
428
  disabled={!hasBlocks}
ui/components/panels/TextBlocksPanel.tsx CHANGED
@@ -7,7 +7,6 @@ import { Languages, LoaderCircleIcon, Trash2Icon } from 'lucide-react'
7
  import { useTextBlocks } from '@/hooks/useTextBlocks'
8
  import { useGetLlm } from '@/lib/api/llm/llm'
9
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
10
- import { usePreferencesStore } from '@/lib/stores/preferencesStore'
11
  import { useProcessing } from '@/lib/machines'
12
  import {
13
  Accordion,
@@ -56,7 +55,6 @@ export function TextBlocksPanel() {
56
  const selectedLanguage = useEditorUiStore.getState().selectedLanguage
57
  const textBlockId = document.textBlocks[blockIndex]?.id
58
  const { renderEffect, renderStroke } = useEditorUiStore.getState()
59
- const { fontFamily } = usePreferencesStore.getState()
60
  send({
61
  type: 'START_TRANSLATE_BLOCK',
62
  documentId,
@@ -67,7 +65,6 @@ export function TextBlocksPanel() {
67
  renderOptions: {
68
  shaderEffect: renderEffect,
69
  shaderStroke: renderStroke,
70
- fontFamily,
71
  },
72
  })
73
  }
 
7
  import { useTextBlocks } from '@/hooks/useTextBlocks'
8
  import { useGetLlm } from '@/lib/api/llm/llm'
9
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
 
10
  import { useProcessing } from '@/lib/machines'
11
  import {
12
  Accordion,
 
55
  const selectedLanguage = useEditorUiStore.getState().selectedLanguage
56
  const textBlockId = document.textBlocks[blockIndex]?.id
57
  const { renderEffect, renderStroke } = useEditorUiStore.getState()
 
58
  send({
59
  type: 'START_TRANSLATE_BLOCK',
60
  documentId,
 
65
  renderOptions: {
66
  shaderEffect: renderEffect,
67
  shaderStroke: renderStroke,
 
68
  },
69
  })
70
  }
ui/components/ui/font-select.tsx CHANGED
@@ -1,6 +1,6 @@
1
  'use client'
2
 
3
- import { useRef, useState, useMemo, useCallback } from 'react'
4
  import { useVirtualizer } from '@tanstack/react-virtual'
5
  import { CheckIcon, ChevronDownIcon } from 'lucide-react'
6
  import {
@@ -8,8 +8,9 @@ import {
8
  PopoverContent,
9
  PopoverTrigger,
10
  } from '@/components/ui/popover'
11
- import { ScrollArea } from '@/components/ui/scroll-area'
12
  import { cn } from '@/lib/utils'
 
13
 
14
  const ITEM_HEIGHT = 28
15
  const MAX_VISIBLE = 10
@@ -17,8 +18,13 @@ const MAX_VISIBLE = 10
17
  type FontOption = {
18
  familyName: string
19
  postScriptName: string
 
 
 
20
  }
21
 
 
 
22
  type FontSelectProps = {
23
  value: string
24
  options: FontOption[]
@@ -31,6 +37,93 @@ type FontSelectProps = {
31
  'data-testid'?: string
32
  }
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  export function FontSelect({
35
  value,
36
  options,
@@ -44,14 +137,23 @@ export function FontSelect({
44
  }: FontSelectProps) {
45
  const [open, setOpen] = useState(false)
46
  const [search, setSearch] = useState('')
 
47
  const scrollRef = useRef<HTMLDivElement>(null)
48
  const inputRef = useRef<HTMLInputElement>(null)
49
 
50
  const filtered = useMemo(() => {
51
- if (!search) return options
52
- const lower = search.toLowerCase()
53
- return options.filter((f) => f.familyName.toLowerCase().includes(lower))
54
- }, [options, search])
 
 
 
 
 
 
 
 
55
 
56
  const virtualizer = useVirtualizer({
57
  count: filtered.length,
@@ -111,6 +213,42 @@ export function FontSelect({
111
  placeholder='Search fonts…'
112
  className='placeholder:text-muted-foreground w-full border-b bg-transparent px-2 py-1.5 text-xs outline-none'
113
  />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  <ScrollArea
115
  className='relative'
116
  style={{ height: listHeight }}
@@ -127,29 +265,18 @@ export function FontSelect({
127
  const selected =
128
  font.postScriptName === value || font.familyName === value
129
  return (
130
- <button
131
  key={vi.key}
132
- type='button'
133
- className={cn(
134
- 'hover:bg-accent hover:text-accent-foreground absolute left-0 flex w-full cursor-default items-center gap-1.5 rounded-sm px-2 text-xs select-none',
135
- selected && 'bg-accent',
136
- )}
137
- style={{
138
- height: ITEM_HEIGHT,
139
- top: vi.start,
140
- fontFamily: font.familyName,
141
- }}
142
  onClick={() => {
143
  onChange(font.postScriptName)
144
  setOpen(false)
145
  setSearch('')
146
  }}
147
- >
148
- <span className='flex size-3 shrink-0 items-center justify-center'>
149
- {selected && <CheckIcon className='size-3' />}
150
- </span>
151
- <span className='truncate'>{font.familyName}</span>
152
- </button>
153
  )
154
  })}
155
  </div>
 
1
  'use client'
2
 
3
+ import { useRef, useState, useMemo, useCallback, useEffect } from 'react'
4
  import { useVirtualizer } from '@tanstack/react-virtual'
5
  import { CheckIcon, ChevronDownIcon } from 'lucide-react'
6
  import {
 
8
  PopoverContent,
9
  PopoverTrigger,
10
  } from '@/components/ui/popover'
11
+ import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
12
  import { cn } from '@/lib/utils'
13
+ import { fetchGoogleFont } from '@/lib/api/system/system'
14
 
15
  const ITEM_HEIGHT = 28
16
  const MAX_VISIBLE = 10
 
18
  type FontOption = {
19
  familyName: string
20
  postScriptName: string
21
+ source: 'system' | 'google'
22
+ category?: string | null
23
+ cached: boolean
24
  }
25
 
26
+ type FontLoadState = 'idle' | 'loading' | 'ready' | 'error'
27
+
28
  type FontSelectProps = {
29
  value: string
30
  options: FontOption[]
 
37
  'data-testid'?: string
38
  }
39
 
40
+ function useGoogleFontPreview(
41
+ family: string,
42
+ source: string,
43
+ isVisible: boolean,
44
+ ) {
45
+ const [state, setState] = useState<FontLoadState>(
46
+ source === 'system' ? 'ready' : 'idle',
47
+ )
48
+ const stateRef = useRef(state)
49
+ stateRef.current = state
50
+
51
+ useEffect(() => {
52
+ if (source !== 'google' || !isVisible || stateRef.current !== 'idle') return
53
+
54
+ let cancelled = false
55
+ setState('loading')
56
+
57
+ fetchGoogleFont(encodeURIComponent(family))
58
+ .then(() => {
59
+ if (cancelled) return
60
+ const url = `/api/v1/fonts/google/${encodeURIComponent(family)}/file`
61
+ const face = new FontFace(family, `url(${url})`)
62
+ return face.load()
63
+ })
64
+ .then((face) => {
65
+ if (cancelled || !face) return
66
+ document.fonts.add(face)
67
+ setState('ready')
68
+ })
69
+ .catch(() => {
70
+ if (!cancelled) setState('error')
71
+ })
72
+
73
+ return () => {
74
+ cancelled = true
75
+ }
76
+ // eslint-disable-next-line react-hooks/exhaustive-deps
77
+ }, [family, source, isVisible])
78
+
79
+ return state
80
+ }
81
+
82
+ function FontRow({
83
+ font,
84
+ selected,
85
+ style,
86
+ isVisible,
87
+ onClick,
88
+ }: {
89
+ font: FontOption
90
+ selected: boolean
91
+ style: React.CSSProperties
92
+ isVisible: boolean
93
+ onClick: () => void
94
+ }) {
95
+ const loadState = useGoogleFontPreview(
96
+ font.familyName,
97
+ font.source,
98
+ isVisible,
99
+ )
100
+
101
+ return (
102
+ <button
103
+ type='button'
104
+ className={cn(
105
+ 'hover:bg-accent hover:text-accent-foreground absolute left-0 flex w-full cursor-default items-center gap-1.5 rounded-sm px-2 text-xs select-none',
106
+ selected && 'bg-accent',
107
+ )}
108
+ style={{
109
+ ...style,
110
+ fontFamily: loadState === 'ready' ? font.familyName : undefined,
111
+ }}
112
+ onClick={onClick}
113
+ >
114
+ <span className='flex size-3 shrink-0 items-center justify-center'>
115
+ {selected && <CheckIcon className='size-3' />}
116
+ </span>
117
+ <span className='truncate'>{font.familyName}</span>
118
+ {font.source === 'google' && (
119
+ <span className='text-muted-foreground ml-auto shrink-0 text-[9px]'>
120
+ {loadState === 'loading' ? '...' : 'G'}
121
+ </span>
122
+ )}
123
+ </button>
124
+ )
125
+ }
126
+
127
  export function FontSelect({
128
  value,
129
  options,
 
137
  }: FontSelectProps) {
138
  const [open, setOpen] = useState(false)
139
  const [search, setSearch] = useState('')
140
+ const [categoryFilter, setCategoryFilter] = useState<string | null>(null)
141
  const scrollRef = useRef<HTMLDivElement>(null)
142
  const inputRef = useRef<HTMLInputElement>(null)
143
 
144
  const filtered = useMemo(() => {
145
+ let result = options
146
+ if (categoryFilter) {
147
+ result = result.filter(
148
+ (f) => f.source === 'system' || f.category === categoryFilter,
149
+ )
150
+ }
151
+ if (search) {
152
+ const lower = search.toLowerCase()
153
+ result = result.filter((f) => f.familyName.toLowerCase().includes(lower))
154
+ }
155
+ return result
156
+ }, [options, search, categoryFilter])
157
 
158
  const virtualizer = useVirtualizer({
159
  count: filtered.length,
 
213
  placeholder='Search fonts…'
214
  className='placeholder:text-muted-foreground w-full border-b bg-transparent px-2 py-1.5 text-xs outline-none'
215
  />
216
+ <ScrollArea className='border-b'>
217
+ <div className='flex gap-0.5 px-1.5 py-1'>
218
+ {['all', 'hand', 'display', 'sans', 'serif', 'mono'].map(
219
+ (cat, i) => {
220
+ const full = [
221
+ 'all',
222
+ 'handwriting',
223
+ 'display',
224
+ 'sans-serif',
225
+ 'serif',
226
+ 'monospace',
227
+ ][i]
228
+ const active =
229
+ cat === 'all' ? !categoryFilter : categoryFilter === full
230
+ return (
231
+ <button
232
+ key={cat}
233
+ type='button'
234
+ className={cn(
235
+ 'shrink-0 rounded-full px-1.5 py-px text-[9px]',
236
+ active
237
+ ? 'bg-primary text-primary-foreground'
238
+ : 'bg-muted text-muted-foreground hover:bg-accent',
239
+ )}
240
+ onClick={() =>
241
+ setCategoryFilter(cat === 'all' ? null : full)
242
+ }
243
+ >
244
+ {cat.charAt(0).toUpperCase() + cat.slice(1)}
245
+ </button>
246
+ )
247
+ },
248
+ )}
249
+ </div>
250
+ <ScrollBar orientation='horizontal' />
251
+ </ScrollArea>
252
  <ScrollArea
253
  className='relative'
254
  style={{ height: listHeight }}
 
265
  const selected =
266
  font.postScriptName === value || font.familyName === value
267
  return (
268
+ <FontRow
269
  key={vi.key}
270
+ font={font}
271
+ selected={selected}
272
+ style={{ height: ITEM_HEIGHT, top: vi.start }}
273
+ isVisible={true}
 
 
 
 
 
 
274
  onClick={() => {
275
  onChange(font.postScriptName)
276
  setOpen(false)
277
  setSearch('')
278
  }}
279
+ />
 
 
 
 
 
280
  )
281
  })}
282
  </div>
ui/hooks/useTextBlocks.ts CHANGED
@@ -62,6 +62,7 @@ export type MappedDocument = {
62
  inpainted?: string
63
  brushLayer?: string
64
  rendered?: string
 
65
  }
66
 
67
  const mapDocumentDetail = (detail: DocumentDetail): MappedDocument => ({
@@ -75,6 +76,7 @@ const mapDocumentDetail = (detail: DocumentDetail): MappedDocument => ({
75
  inpainted: detail.inpainted ?? undefined,
76
  brushLayer: detail.brushLayer ?? undefined,
77
  rendered: detail.rendered ?? undefined,
 
78
  })
79
 
80
  const toTextBlockInput = (block: TextBlock): TextBlockInput => ({
 
62
  inpainted?: string
63
  brushLayer?: string
64
  rendered?: string
65
+ style?: { defaultFont?: string | null }
66
  }
67
 
68
  const mapDocumentDetail = (detail: DocumentDetail): MappedDocument => ({
 
76
  inpainted: detail.inpainted ?? undefined,
77
  brushLayer: detail.brushLayer ?? undefined,
78
  rendered: detail.rendered ?? undefined,
79
+ style: detail.style ?? undefined,
80
  })
81
 
82
  const toTextBlockInput = (block: TextBlock): TextBlockInput => ({
ui/lib/api/documents/documents.ts CHANGED
@@ -27,6 +27,7 @@ import type {
27
  ImportDocumentsBody,
28
  ImportDocumentsParams,
29
  ImportResult,
 
30
  } from '../schemas'
31
 
32
  import { fetchApi } from '.././fetch'
@@ -406,6 +407,92 @@ export function useGetDocument<
406
  return { ...query, queryKey: queryOptions.queryKey }
407
  }
408
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  export const getGetDocumentThumbnailUrl = (
410
  documentId: string,
411
  params?: GetDocumentThumbnailParams,
 
27
  ImportDocumentsBody,
28
  ImportDocumentsParams,
29
  ImportResult,
30
+ UpdateDocumentStyleRequest,
31
  } from '../schemas'
32
 
33
  import { fetchApi } from '.././fetch'
 
407
  return { ...query, queryKey: queryOptions.queryKey }
408
  }
409
 
410
+ export const getUpdateDocumentStyleUrl = (documentId: string) => {
411
+ return `/api/v1/documents/${documentId}/style`
412
+ }
413
+
414
+ export const updateDocumentStyle = async (
415
+ documentId: string,
416
+ updateDocumentStyleRequest: UpdateDocumentStyleRequest,
417
+ options?: RequestInit,
418
+ ): Promise<UpdateDocumentStyleRequest> => {
419
+ return fetchApi<UpdateDocumentStyleRequest>(
420
+ getUpdateDocumentStyleUrl(documentId),
421
+ {
422
+ ...options,
423
+ method: 'PATCH',
424
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
425
+ body: JSON.stringify(updateDocumentStyleRequest),
426
+ },
427
+ )
428
+ }
429
+
430
+ export const getUpdateDocumentStyleMutationOptions = <
431
+ TError = ApiError,
432
+ TContext = unknown,
433
+ >(options?: {
434
+ mutation?: UseMutationOptions<
435
+ Awaited<ReturnType<typeof updateDocumentStyle>>,
436
+ TError,
437
+ { documentId: string; data: UpdateDocumentStyleRequest },
438
+ TContext
439
+ >
440
+ request?: SecondParameter<typeof fetchApi>
441
+ }): UseMutationOptions<
442
+ Awaited<ReturnType<typeof updateDocumentStyle>>,
443
+ TError,
444
+ { documentId: string; data: UpdateDocumentStyleRequest },
445
+ TContext
446
+ > => {
447
+ const mutationKey = ['updateDocumentStyle']
448
+ const { mutation: mutationOptions, request: requestOptions } = options
449
+ ? options.mutation &&
450
+ 'mutationKey' in options.mutation &&
451
+ options.mutation.mutationKey
452
+ ? options
453
+ : { ...options, mutation: { ...options.mutation, mutationKey } }
454
+ : { mutation: { mutationKey }, request: undefined }
455
+
456
+ const mutationFn: MutationFunction<
457
+ Awaited<ReturnType<typeof updateDocumentStyle>>,
458
+ { documentId: string; data: UpdateDocumentStyleRequest }
459
+ > = (props) => {
460
+ const { documentId, data } = props ?? {}
461
+
462
+ return updateDocumentStyle(documentId, data, requestOptions)
463
+ }
464
+
465
+ return { mutationFn, ...mutationOptions }
466
+ }
467
+
468
+ export type UpdateDocumentStyleMutationResult = NonNullable<
469
+ Awaited<ReturnType<typeof updateDocumentStyle>>
470
+ >
471
+ export type UpdateDocumentStyleMutationBody = UpdateDocumentStyleRequest
472
+ export type UpdateDocumentStyleMutationError = ApiError
473
+
474
+ export const useUpdateDocumentStyle = <TError = ApiError, TContext = unknown>(
475
+ options?: {
476
+ mutation?: UseMutationOptions<
477
+ Awaited<ReturnType<typeof updateDocumentStyle>>,
478
+ TError,
479
+ { documentId: string; data: UpdateDocumentStyleRequest },
480
+ TContext
481
+ >
482
+ request?: SecondParameter<typeof fetchApi>
483
+ },
484
+ queryClient?: QueryClient,
485
+ ): UseMutationResult<
486
+ Awaited<ReturnType<typeof updateDocumentStyle>>,
487
+ TError,
488
+ { documentId: string; data: UpdateDocumentStyleRequest },
489
+ TContext
490
+ > => {
491
+ return useMutation(
492
+ getUpdateDocumentStyleMutationOptions(options),
493
+ queryClient,
494
+ )
495
+ }
496
  export const getGetDocumentThumbnailUrl = (
497
  documentId: string,
498
  params?: GetDocumentThumbnailParams,
ui/lib/api/schemas/documentDetail.ts CHANGED
@@ -3,6 +3,7 @@
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
6
  import type { TextBlockDetail } from './textBlockDetail'
7
 
8
  export interface DocumentDetail {
@@ -32,6 +33,7 @@ export interface DocumentDetail {
32
  * @nullable
33
  */
34
  segment?: string | null
 
35
  textBlocks: TextBlockDetail[]
36
  /** @minimum 0 */
37
  width: number
 
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
6
+ import type { DocumentStyle } from './documentStyle'
7
  import type { TextBlockDetail } from './textBlockDetail'
8
 
9
  export interface DocumentDetail {
 
33
  * @nullable
34
  */
35
  segment?: string | null
36
+ style?: null | DocumentStyle
37
  textBlocks: TextBlockDetail[]
38
  /** @minimum 0 */
39
  width: number
ui/lib/api/schemas/documentStyle.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.6.2 🍺
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+
7
+ export interface DocumentStyle {
8
+ /** @nullable */
9
+ defaultFont?: string | null
10
+ }
ui/lib/api/schemas/fontFaceInfo.ts CHANGED
@@ -3,8 +3,13 @@
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
6
 
7
  export interface FontFaceInfo {
 
 
 
8
  familyName: string
9
  postScriptName: string
 
10
  }
 
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
6
+ import type { FontSource } from './fontSource'
7
 
8
  export interface FontFaceInfo {
9
+ cached: boolean
10
+ /** @nullable */
11
+ category?: string | null
12
  familyName: string
13
  postScriptName: string
14
+ source: FontSource
15
  }
ui/lib/api/schemas/fontSource.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.6.2 🍺
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+
7
+ export type FontSource = (typeof FontSource)[keyof typeof FontSource]
8
+
9
+ export const FontSource = {
10
+ system: 'system',
11
+ google: 'google',
12
+ } as const
ui/lib/api/schemas/googleFontCatalogEntry.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.6.2 🍺
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+
7
+ export interface GoogleFontCatalogEntry {
8
+ cached: boolean
9
+ category: string
10
+ family: string
11
+ subsets: string[]
12
+ }
ui/lib/api/schemas/googleFontCatalogResponse.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.6.2 🍺
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+ import type { GoogleFontCatalogEntry } from './googleFontCatalogEntry'
7
+
8
+ export interface GoogleFontCatalogResponse {
9
+ fonts: GoogleFontCatalogEntry[]
10
+ recommended: string[]
11
+ }
ui/lib/api/schemas/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from './brushRegionRequest'
9
  export * from './createTextBlock'
10
  export * from './dataConfig'
11
  export * from './documentDetail'
 
12
  export * from './documentSummary'
13
  export * from './downloadState'
14
  export * from './engineCatalogEntry'
@@ -18,10 +19,13 @@ export * from './exportLayer'
18
  export * from './exportResult'
19
  export * from './fontFaceInfo'
20
  export * from './fontPrediction'
 
21
  export * from './getConfig200'
22
  export * from './getDocumentThumbnailParams'
23
  export * from './getEngineCatalog200'
24
  export * from './getLlmCatalogParams'
 
 
25
  export * from './httpConfig'
26
  export * from './importDocumentsBody'
27
  export * from './importDocumentsParams'
@@ -62,3 +66,4 @@ export * from './transferStatus'
62
  export * from './translateRequest'
63
  export * from './updateConfig200'
64
  export * from './updateConfigBody'
 
 
9
  export * from './createTextBlock'
10
  export * from './dataConfig'
11
  export * from './documentDetail'
12
+ export * from './documentStyle'
13
  export * from './documentSummary'
14
  export * from './downloadState'
15
  export * from './engineCatalogEntry'
 
19
  export * from './exportResult'
20
  export * from './fontFaceInfo'
21
  export * from './fontPrediction'
22
+ export * from './fontSource'
23
  export * from './getConfig200'
24
  export * from './getDocumentThumbnailParams'
25
  export * from './getEngineCatalog200'
26
  export * from './getLlmCatalogParams'
27
+ export * from './googleFontCatalogEntry'
28
+ export * from './googleFontCatalogResponse'
29
  export * from './httpConfig'
30
  export * from './importDocumentsBody'
31
  export * from './importDocumentsParams'
 
66
  export * from './translateRequest'
67
  export * from './updateConfig200'
68
  export * from './updateConfigBody'
69
+ export * from './updateDocumentStyleRequest'
ui/lib/api/schemas/pipelineJobRequest.ts CHANGED
@@ -11,8 +11,6 @@ export interface PipelineJobRequest {
11
  /** @nullable */
12
  documentId?: string | null
13
  /** @nullable */
14
- fontFamily?: string | null
15
- /** @nullable */
16
  language?: string | null
17
  llm?: null | PipelineLlmRequest
18
  shaderEffect?: null | TextShaderEffect
 
11
  /** @nullable */
12
  documentId?: string | null
13
  /** @nullable */
 
 
14
  language?: string | null
15
  llm?: null | PipelineLlmRequest
16
  shaderEffect?: null | TextShaderEffect
ui/lib/api/schemas/renderRequest.ts CHANGED
@@ -7,8 +7,6 @@ import type { TextShaderEffect } from './textShaderEffect'
7
  import type { TextStrokeStyle } from './textStrokeStyle'
8
 
9
  export interface RenderRequest {
10
- /** @nullable */
11
- fontFamily?: string | null
12
  shaderEffect?: null | TextShaderEffect
13
  shaderStroke?: null | TextStrokeStyle
14
  /** @nullable */
 
7
  import type { TextStrokeStyle } from './textStrokeStyle'
8
 
9
  export interface RenderRequest {
 
 
10
  shaderEffect?: null | TextShaderEffect
11
  shaderStroke?: null | TextStrokeStyle
12
  /** @nullable */
ui/lib/api/schemas/updateDocumentStyleRequest.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.6.2 🍺
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+
7
+ export interface UpdateDocumentStyleRequest {
8
+ /** @nullable */
9
+ defaultFont?: string | null
10
+ }
ui/lib/api/system/system.ts CHANGED
@@ -24,6 +24,7 @@ import type {
24
  FontFaceInfo,
25
  GetConfig200,
26
  GetEngineCatalog200,
 
27
  MetaInfo,
28
  UpdateConfig200,
29
  UpdateConfigBody,
@@ -529,6 +530,403 @@ export function useListFonts<
529
  return { ...query, queryKey: queryOptions.queryKey }
530
  }
531
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
  export const getGetMetaUrl = () => {
533
  return `/api/v1/meta`
534
  }
 
24
  FontFaceInfo,
25
  GetConfig200,
26
  GetEngineCatalog200,
27
+ GoogleFontCatalogResponse,
28
  MetaInfo,
29
  UpdateConfig200,
30
  UpdateConfigBody,
 
530
  return { ...query, queryKey: queryOptions.queryKey }
531
  }
532
 
533
+ export const getGetGoogleFontsCatalogUrl = () => {
534
+ return `/api/v1/fonts/google/catalog`
535
+ }
536
+
537
+ export const getGoogleFontsCatalog = async (
538
+ options?: RequestInit,
539
+ ): Promise<GoogleFontCatalogResponse> => {
540
+ return fetchApi<GoogleFontCatalogResponse>(getGetGoogleFontsCatalogUrl(), {
541
+ ...options,
542
+ method: 'GET',
543
+ })
544
+ }
545
+
546
+ export const getGetGoogleFontsCatalogQueryKey = () => {
547
+ return [`/api/v1/fonts/google/catalog`] as const
548
+ }
549
+
550
+ export const getGetGoogleFontsCatalogQueryOptions = <
551
+ TData = Awaited<ReturnType<typeof getGoogleFontsCatalog>>,
552
+ TError = ApiError,
553
+ >(options?: {
554
+ query?: Partial<
555
+ UseQueryOptions<
556
+ Awaited<ReturnType<typeof getGoogleFontsCatalog>>,
557
+ TError,
558
+ TData
559
+ >
560
+ >
561
+ request?: SecondParameter<typeof fetchApi>
562
+ }) => {
563
+ const { query: queryOptions, request: requestOptions } = options ?? {}
564
+
565
+ const queryKey = queryOptions?.queryKey ?? getGetGoogleFontsCatalogQueryKey()
566
+
567
+ const queryFn: QueryFunction<
568
+ Awaited<ReturnType<typeof getGoogleFontsCatalog>>
569
+ > = ({ signal }) => getGoogleFontsCatalog({ signal, ...requestOptions })
570
+
571
+ return {
572
+ queryKey,
573
+ queryFn,
574
+ gcTime: 300000,
575
+ retry: 1,
576
+ ...queryOptions,
577
+ } as UseQueryOptions<
578
+ Awaited<ReturnType<typeof getGoogleFontsCatalog>>,
579
+ TError,
580
+ TData
581
+ > & { queryKey: DataTag<QueryKey, TData, TError> }
582
+ }
583
+
584
+ export type GetGoogleFontsCatalogQueryResult = NonNullable<
585
+ Awaited<ReturnType<typeof getGoogleFontsCatalog>>
586
+ >
587
+ export type GetGoogleFontsCatalogQueryError = ApiError
588
+
589
+ export function useGetGoogleFontsCatalog<
590
+ TData = Awaited<ReturnType<typeof getGoogleFontsCatalog>>,
591
+ TError = ApiError,
592
+ >(
593
+ options: {
594
+ query: Partial<
595
+ UseQueryOptions<
596
+ Awaited<ReturnType<typeof getGoogleFontsCatalog>>,
597
+ TError,
598
+ TData
599
+ >
600
+ > &
601
+ Pick<
602
+ DefinedInitialDataOptions<
603
+ Awaited<ReturnType<typeof getGoogleFontsCatalog>>,
604
+ TError,
605
+ Awaited<ReturnType<typeof getGoogleFontsCatalog>>
606
+ >,
607
+ 'initialData'
608
+ >
609
+ request?: SecondParameter<typeof fetchApi>
610
+ },
611
+ queryClient?: QueryClient,
612
+ ): DefinedUseQueryResult<TData, TError> & {
613
+ queryKey: DataTag<QueryKey, TData, TError>
614
+ }
615
+ export function useGetGoogleFontsCatalog<
616
+ TData = Awaited<ReturnType<typeof getGoogleFontsCatalog>>,
617
+ TError = ApiError,
618
+ >(
619
+ options?: {
620
+ query?: Partial<
621
+ UseQueryOptions<
622
+ Awaited<ReturnType<typeof getGoogleFontsCatalog>>,
623
+ TError,
624
+ TData
625
+ >
626
+ > &
627
+ Pick<
628
+ UndefinedInitialDataOptions<
629
+ Awaited<ReturnType<typeof getGoogleFontsCatalog>>,
630
+ TError,
631
+ Awaited<ReturnType<typeof getGoogleFontsCatalog>>
632
+ >,
633
+ 'initialData'
634
+ >
635
+ request?: SecondParameter<typeof fetchApi>
636
+ },
637
+ queryClient?: QueryClient,
638
+ ): UseQueryResult<TData, TError> & {
639
+ queryKey: DataTag<QueryKey, TData, TError>
640
+ }
641
+ export function useGetGoogleFontsCatalog<
642
+ TData = Awaited<ReturnType<typeof getGoogleFontsCatalog>>,
643
+ TError = ApiError,
644
+ >(
645
+ options?: {
646
+ query?: Partial<
647
+ UseQueryOptions<
648
+ Awaited<ReturnType<typeof getGoogleFontsCatalog>>,
649
+ TError,
650
+ TData
651
+ >
652
+ >
653
+ request?: SecondParameter<typeof fetchApi>
654
+ },
655
+ queryClient?: QueryClient,
656
+ ): UseQueryResult<TData, TError> & {
657
+ queryKey: DataTag<QueryKey, TData, TError>
658
+ }
659
+
660
+ export function useGetGoogleFontsCatalog<
661
+ TData = Awaited<ReturnType<typeof getGoogleFontsCatalog>>,
662
+ TError = ApiError,
663
+ >(
664
+ options?: {
665
+ query?: Partial<
666
+ UseQueryOptions<
667
+ Awaited<ReturnType<typeof getGoogleFontsCatalog>>,
668
+ TError,
669
+ TData
670
+ >
671
+ >
672
+ request?: SecondParameter<typeof fetchApi>
673
+ },
674
+ queryClient?: QueryClient,
675
+ ): UseQueryResult<TData, TError> & {
676
+ queryKey: DataTag<QueryKey, TData, TError>
677
+ } {
678
+ const queryOptions = getGetGoogleFontsCatalogQueryOptions(options)
679
+
680
+ const query = useQuery(queryOptions, queryClient) as UseQueryResult<
681
+ TData,
682
+ TError
683
+ > & { queryKey: DataTag<QueryKey, TData, TError> }
684
+
685
+ return { ...query, queryKey: queryOptions.queryKey }
686
+ }
687
+
688
+ export const getFetchGoogleFontUrl = (family: string) => {
689
+ return `/api/v1/fonts/google/${family}/fetch`
690
+ }
691
+
692
+ export const fetchGoogleFont = async (
693
+ family: string,
694
+ options?: RequestInit,
695
+ ): Promise<FontFaceInfo> => {
696
+ return fetchApi<FontFaceInfo>(getFetchGoogleFontUrl(family), {
697
+ ...options,
698
+ method: 'POST',
699
+ })
700
+ }
701
+
702
+ export const getFetchGoogleFontMutationOptions = <
703
+ TError = ApiError,
704
+ TContext = unknown,
705
+ >(options?: {
706
+ mutation?: UseMutationOptions<
707
+ Awaited<ReturnType<typeof fetchGoogleFont>>,
708
+ TError,
709
+ { family: string },
710
+ TContext
711
+ >
712
+ request?: SecondParameter<typeof fetchApi>
713
+ }): UseMutationOptions<
714
+ Awaited<ReturnType<typeof fetchGoogleFont>>,
715
+ TError,
716
+ { family: string },
717
+ TContext
718
+ > => {
719
+ const mutationKey = ['fetchGoogleFont']
720
+ const { mutation: mutationOptions, request: requestOptions } = options
721
+ ? options.mutation &&
722
+ 'mutationKey' in options.mutation &&
723
+ options.mutation.mutationKey
724
+ ? options
725
+ : { ...options, mutation: { ...options.mutation, mutationKey } }
726
+ : { mutation: { mutationKey }, request: undefined }
727
+
728
+ const mutationFn: MutationFunction<
729
+ Awaited<ReturnType<typeof fetchGoogleFont>>,
730
+ { family: string }
731
+ > = (props) => {
732
+ const { family } = props ?? {}
733
+
734
+ return fetchGoogleFont(family, requestOptions)
735
+ }
736
+
737
+ return { mutationFn, ...mutationOptions }
738
+ }
739
+
740
+ export type FetchGoogleFontMutationResult = NonNullable<
741
+ Awaited<ReturnType<typeof fetchGoogleFont>>
742
+ >
743
+
744
+ export type FetchGoogleFontMutationError = ApiError
745
+
746
+ export const useFetchGoogleFont = <TError = ApiError, TContext = unknown>(
747
+ options?: {
748
+ mutation?: UseMutationOptions<
749
+ Awaited<ReturnType<typeof fetchGoogleFont>>,
750
+ TError,
751
+ { family: string },
752
+ TContext
753
+ >
754
+ request?: SecondParameter<typeof fetchApi>
755
+ },
756
+ queryClient?: QueryClient,
757
+ ): UseMutationResult<
758
+ Awaited<ReturnType<typeof fetchGoogleFont>>,
759
+ TError,
760
+ { family: string },
761
+ TContext
762
+ > => {
763
+ return useMutation(getFetchGoogleFontMutationOptions(options), queryClient)
764
+ }
765
+ export const getGetGoogleFontFileUrl = (family: string) => {
766
+ return `/api/v1/fonts/google/${family}/file`
767
+ }
768
+
769
+ export const getGoogleFontFile = async (
770
+ family: string,
771
+ options?: RequestInit,
772
+ ): Promise<Blob> => {
773
+ return fetchApi<Blob>(getGetGoogleFontFileUrl(family), {
774
+ ...options,
775
+ method: 'GET',
776
+ })
777
+ }
778
+
779
+ export const getGetGoogleFontFileQueryKey = (family: string) => {
780
+ return [`/api/v1/fonts/google/${family}/file`] as const
781
+ }
782
+
783
+ export const getGetGoogleFontFileQueryOptions = <
784
+ TData = Awaited<ReturnType<typeof getGoogleFontFile>>,
785
+ TError = ApiError,
786
+ >(
787
+ family: string,
788
+ options?: {
789
+ query?: Partial<
790
+ UseQueryOptions<
791
+ Awaited<ReturnType<typeof getGoogleFontFile>>,
792
+ TError,
793
+ TData
794
+ >
795
+ >
796
+ request?: SecondParameter<typeof fetchApi>
797
+ },
798
+ ) => {
799
+ const { query: queryOptions, request: requestOptions } = options ?? {}
800
+
801
+ const queryKey =
802
+ queryOptions?.queryKey ?? getGetGoogleFontFileQueryKey(family)
803
+
804
+ const queryFn: QueryFunction<
805
+ Awaited<ReturnType<typeof getGoogleFontFile>>
806
+ > = ({ signal }) => getGoogleFontFile(family, { signal, ...requestOptions })
807
+
808
+ return {
809
+ queryKey,
810
+ queryFn,
811
+ enabled: !!family,
812
+ gcTime: 300000,
813
+ retry: 1,
814
+ ...queryOptions,
815
+ } as UseQueryOptions<
816
+ Awaited<ReturnType<typeof getGoogleFontFile>>,
817
+ TError,
818
+ TData
819
+ > & { queryKey: DataTag<QueryKey, TData, TError> }
820
+ }
821
+
822
+ export type GetGoogleFontFileQueryResult = NonNullable<
823
+ Awaited<ReturnType<typeof getGoogleFontFile>>
824
+ >
825
+ export type GetGoogleFontFileQueryError = ApiError
826
+
827
+ export function useGetGoogleFontFile<
828
+ TData = Awaited<ReturnType<typeof getGoogleFontFile>>,
829
+ TError = ApiError,
830
+ >(
831
+ family: string,
832
+ options: {
833
+ query: Partial<
834
+ UseQueryOptions<
835
+ Awaited<ReturnType<typeof getGoogleFontFile>>,
836
+ TError,
837
+ TData
838
+ >
839
+ > &
840
+ Pick<
841
+ DefinedInitialDataOptions<
842
+ Awaited<ReturnType<typeof getGoogleFontFile>>,
843
+ TError,
844
+ Awaited<ReturnType<typeof getGoogleFontFile>>
845
+ >,
846
+ 'initialData'
847
+ >
848
+ request?: SecondParameter<typeof fetchApi>
849
+ },
850
+ queryClient?: QueryClient,
851
+ ): DefinedUseQueryResult<TData, TError> & {
852
+ queryKey: DataTag<QueryKey, TData, TError>
853
+ }
854
+ export function useGetGoogleFontFile<
855
+ TData = Awaited<ReturnType<typeof getGoogleFontFile>>,
856
+ TError = ApiError,
857
+ >(
858
+ family: string,
859
+ options?: {
860
+ query?: Partial<
861
+ UseQueryOptions<
862
+ Awaited<ReturnType<typeof getGoogleFontFile>>,
863
+ TError,
864
+ TData
865
+ >
866
+ > &
867
+ Pick<
868
+ UndefinedInitialDataOptions<
869
+ Awaited<ReturnType<typeof getGoogleFontFile>>,
870
+ TError,
871
+ Awaited<ReturnType<typeof getGoogleFontFile>>
872
+ >,
873
+ 'initialData'
874
+ >
875
+ request?: SecondParameter<typeof fetchApi>
876
+ },
877
+ queryClient?: QueryClient,
878
+ ): UseQueryResult<TData, TError> & {
879
+ queryKey: DataTag<QueryKey, TData, TError>
880
+ }
881
+ export function useGetGoogleFontFile<
882
+ TData = Awaited<ReturnType<typeof getGoogleFontFile>>,
883
+ TError = ApiError,
884
+ >(
885
+ family: string,
886
+ options?: {
887
+ query?: Partial<
888
+ UseQueryOptions<
889
+ Awaited<ReturnType<typeof getGoogleFontFile>>,
890
+ TError,
891
+ TData
892
+ >
893
+ >
894
+ request?: SecondParameter<typeof fetchApi>
895
+ },
896
+ queryClient?: QueryClient,
897
+ ): UseQueryResult<TData, TError> & {
898
+ queryKey: DataTag<QueryKey, TData, TError>
899
+ }
900
+
901
+ export function useGetGoogleFontFile<
902
+ TData = Awaited<ReturnType<typeof getGoogleFontFile>>,
903
+ TError = ApiError,
904
+ >(
905
+ family: string,
906
+ options?: {
907
+ query?: Partial<
908
+ UseQueryOptions<
909
+ Awaited<ReturnType<typeof getGoogleFontFile>>,
910
+ TError,
911
+ TData
912
+ >
913
+ >
914
+ request?: SecondParameter<typeof fetchApi>
915
+ },
916
+ queryClient?: QueryClient,
917
+ ): UseQueryResult<TData, TError> & {
918
+ queryKey: DataTag<QueryKey, TData, TError>
919
+ } {
920
+ const queryOptions = getGetGoogleFontFileQueryOptions(family, options)
921
+
922
+ const query = useQuery(queryOptions, queryClient) as UseQueryResult<
923
+ TData,
924
+ TError
925
+ > & { queryKey: DataTag<QueryKey, TData, TError> }
926
+
927
+ return { ...query, queryKey: queryOptions.queryKey }
928
+ }
929
+
930
  export const getGetMetaUrl = () => {
931
  return `/api/v1/meta`
932
  }
ui/lib/stores/preferencesStore.ts CHANGED
@@ -9,8 +9,6 @@ type PreferencesState = {
9
  color: string
10
  }
11
  setBrushConfig: (config: Partial<PreferencesState['brushConfig']>) => void
12
- fontFamily?: string
13
- setFontFamily: (font?: string) => void
14
  resetPreferences: () => void
15
  }
16
 
@@ -19,7 +17,6 @@ const initialPreferences = {
19
  size: 36,
20
  color: '#ffffff',
21
  },
22
- fontFamily: undefined as string | undefined,
23
  }
24
 
25
  export const usePreferencesStore = create<PreferencesState>()(
@@ -33,7 +30,6 @@ export const usePreferencesStore = create<PreferencesState>()(
33
  ...config,
34
  },
35
  })),
36
- setFontFamily: (font) => set({ fontFamily: font }),
37
  resetPreferences: () => set({ ...initialPreferences }),
38
  }),
39
  {
@@ -53,7 +49,6 @@ export const usePreferencesStore = create<PreferencesState>()(
53
  },
54
  partialize: (state) => ({
55
  brushConfig: state.brushConfig,
56
- fontFamily: state.fontFamily,
57
  }),
58
  },
59
  ),
 
9
  color: string
10
  }
11
  setBrushConfig: (config: Partial<PreferencesState['brushConfig']>) => void
 
 
12
  resetPreferences: () => void
13
  }
14
 
 
17
  size: 36,
18
  color: '#ffffff',
19
  },
 
20
  }
21
 
22
  export const usePreferencesStore = create<PreferencesState>()(
 
30
  ...config,
31
  },
32
  })),
 
33
  resetPreferences: () => set({ ...initialPreferences }),
34
  }),
35
  {
 
49
  },
50
  partialize: (state) => ({
51
  brushConfig: state.brushConfig,
 
52
  }),
53
  },
54
  ),
ui/openapi.json CHANGED
@@ -816,6 +816,65 @@
816
  }
817
  }
818
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
819
  "/documents/{document_id}/text-blocks": {
820
  "put": {
821
  "tags": ["text-blocks"],
@@ -1316,6 +1375,128 @@
1316
  }
1317
  }
1318
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1319
  "/jobs": {
1320
  "get": {
1321
  "tags": ["jobs"],
@@ -1718,6 +1899,16 @@
1718
  "type": ["string", "null"],
1719
  "description": "Blob hash for the segmentation mask layer."
1720
  },
 
 
 
 
 
 
 
 
 
 
1721
  "textBlocks": {
1722
  "type": "array",
1723
  "items": {
@@ -1731,6 +1922,14 @@
1731
  }
1732
  }
1733
  },
 
 
 
 
 
 
 
 
1734
  "DocumentSummary": {
1735
  "type": "object",
1736
  "required": [
@@ -1856,13 +2055,22 @@
1856
  },
1857
  "FontFaceInfo": {
1858
  "type": "object",
1859
- "required": ["familyName", "postScriptName"],
1860
  "properties": {
 
 
 
 
 
 
1861
  "familyName": {
1862
  "type": "string"
1863
  },
1864
  "postScriptName": {
1865
  "type": "string"
 
 
 
1866
  }
1867
  }
1868
  },
@@ -1929,6 +2137,49 @@
1929
  }
1930
  }
1931
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1932
  "HttpConfig": {
1933
  "type": "object",
1934
  "properties": {
@@ -2304,9 +2555,6 @@
2304
  "documentId": {
2305
  "type": ["string", "null"]
2306
  },
2307
- "fontFamily": {
2308
- "type": ["string", "null"]
2309
- },
2310
  "language": {
2311
  "type": ["string", "null"]
2312
  },
@@ -2406,9 +2654,6 @@
2406
  "RenderRequest": {
2407
  "type": "object",
2408
  "properties": {
2409
- "fontFamily": {
2410
- "type": ["string", "null"]
2411
- },
2412
  "shaderEffect": {
2413
  "oneOf": [
2414
  {
@@ -2756,6 +3001,14 @@
2756
  "type": ["string", "null"]
2757
  }
2758
  }
 
 
 
 
 
 
 
 
2759
  }
2760
  }
2761
  }
 
816
  }
817
  }
818
  },
819
+ "/documents/{document_id}/style": {
820
+ "patch": {
821
+ "tags": ["documents"],
822
+ "operationId": "updateDocumentStyle",
823
+ "parameters": [
824
+ {
825
+ "name": "document_id",
826
+ "in": "path",
827
+ "description": "Document ID",
828
+ "required": true,
829
+ "schema": {
830
+ "type": "string"
831
+ }
832
+ }
833
+ ],
834
+ "requestBody": {
835
+ "content": {
836
+ "application/json": {
837
+ "schema": {
838
+ "$ref": "#/components/schemas/UpdateDocumentStyleRequest"
839
+ }
840
+ }
841
+ },
842
+ "required": true
843
+ },
844
+ "responses": {
845
+ "200": {
846
+ "description": "",
847
+ "content": {
848
+ "application/json": {
849
+ "schema": {
850
+ "$ref": "#/components/schemas/UpdateDocumentStyleRequest"
851
+ }
852
+ }
853
+ }
854
+ },
855
+ "404": {
856
+ "description": "",
857
+ "content": {
858
+ "application/json": {
859
+ "schema": {
860
+ "$ref": "#/components/schemas/ApiError"
861
+ }
862
+ }
863
+ }
864
+ },
865
+ "503": {
866
+ "description": "",
867
+ "content": {
868
+ "application/json": {
869
+ "schema": {
870
+ "$ref": "#/components/schemas/ApiError"
871
+ }
872
+ }
873
+ }
874
+ }
875
+ }
876
+ }
877
+ },
878
  "/documents/{document_id}/text-blocks": {
879
  "put": {
880
  "tags": ["text-blocks"],
 
1375
  }
1376
  }
1377
  },
1378
+ "/fonts/google/catalog": {
1379
+ "get": {
1380
+ "tags": ["system"],
1381
+ "operationId": "getGoogleFontsCatalog",
1382
+ "responses": {
1383
+ "200": {
1384
+ "description": "",
1385
+ "content": {
1386
+ "application/json": {
1387
+ "schema": {
1388
+ "$ref": "#/components/schemas/GoogleFontCatalogResponse"
1389
+ }
1390
+ }
1391
+ }
1392
+ },
1393
+ "503": {
1394
+ "description": "",
1395
+ "content": {
1396
+ "application/json": {
1397
+ "schema": {
1398
+ "$ref": "#/components/schemas/ApiError"
1399
+ }
1400
+ }
1401
+ }
1402
+ }
1403
+ }
1404
+ }
1405
+ },
1406
+ "/fonts/google/{family}/fetch": {
1407
+ "post": {
1408
+ "tags": ["system"],
1409
+ "operationId": "fetchGoogleFont",
1410
+ "parameters": [
1411
+ {
1412
+ "name": "family",
1413
+ "in": "path",
1414
+ "description": "Font family name",
1415
+ "required": true,
1416
+ "schema": {
1417
+ "type": "string"
1418
+ }
1419
+ }
1420
+ ],
1421
+ "responses": {
1422
+ "200": {
1423
+ "description": "",
1424
+ "content": {
1425
+ "application/json": {
1426
+ "schema": {
1427
+ "$ref": "#/components/schemas/FontFaceInfo"
1428
+ }
1429
+ }
1430
+ }
1431
+ },
1432
+ "404": {
1433
+ "description": "",
1434
+ "content": {
1435
+ "application/json": {
1436
+ "schema": {
1437
+ "$ref": "#/components/schemas/ApiError"
1438
+ }
1439
+ }
1440
+ }
1441
+ },
1442
+ "503": {
1443
+ "description": "",
1444
+ "content": {
1445
+ "application/json": {
1446
+ "schema": {
1447
+ "$ref": "#/components/schemas/ApiError"
1448
+ }
1449
+ }
1450
+ }
1451
+ }
1452
+ }
1453
+ }
1454
+ },
1455
+ "/fonts/google/{family}/file": {
1456
+ "get": {
1457
+ "tags": ["system"],
1458
+ "operationId": "getGoogleFontFile",
1459
+ "parameters": [
1460
+ {
1461
+ "name": "family",
1462
+ "in": "path",
1463
+ "description": "Font family name",
1464
+ "required": true,
1465
+ "schema": {
1466
+ "type": "string"
1467
+ }
1468
+ }
1469
+ ],
1470
+ "responses": {
1471
+ "200": {
1472
+ "description": "Font file bytes",
1473
+ "content": {
1474
+ "font/ttf": {}
1475
+ }
1476
+ },
1477
+ "404": {
1478
+ "description": "",
1479
+ "content": {
1480
+ "application/json": {
1481
+ "schema": {
1482
+ "$ref": "#/components/schemas/ApiError"
1483
+ }
1484
+ }
1485
+ }
1486
+ },
1487
+ "503": {
1488
+ "description": "",
1489
+ "content": {
1490
+ "application/json": {
1491
+ "schema": {
1492
+ "$ref": "#/components/schemas/ApiError"
1493
+ }
1494
+ }
1495
+ }
1496
+ }
1497
+ }
1498
+ }
1499
+ },
1500
  "/jobs": {
1501
  "get": {
1502
  "tags": ["jobs"],
 
1899
  "type": ["string", "null"],
1900
  "description": "Blob hash for the segmentation mask layer."
1901
  },
1902
+ "style": {
1903
+ "oneOf": [
1904
+ {
1905
+ "type": "null"
1906
+ },
1907
+ {
1908
+ "$ref": "#/components/schemas/DocumentStyle"
1909
+ }
1910
+ ]
1911
+ },
1912
  "textBlocks": {
1913
  "type": "array",
1914
  "items": {
 
1922
  }
1923
  }
1924
  },
1925
+ "DocumentStyle": {
1926
+ "type": "object",
1927
+ "properties": {
1928
+ "defaultFont": {
1929
+ "type": ["string", "null"]
1930
+ }
1931
+ }
1932
+ },
1933
  "DocumentSummary": {
1934
  "type": "object",
1935
  "required": [
 
2055
  },
2056
  "FontFaceInfo": {
2057
  "type": "object",
2058
+ "required": ["familyName", "postScriptName", "source", "cached"],
2059
  "properties": {
2060
+ "cached": {
2061
+ "type": "boolean"
2062
+ },
2063
+ "category": {
2064
+ "type": ["string", "null"]
2065
+ },
2066
  "familyName": {
2067
  "type": "string"
2068
  },
2069
  "postScriptName": {
2070
  "type": "string"
2071
+ },
2072
+ "source": {
2073
+ "$ref": "#/components/schemas/FontSource"
2074
  }
2075
  }
2076
  },
 
2137
  }
2138
  }
2139
  },
2140
+ "FontSource": {
2141
+ "type": "string",
2142
+ "enum": ["system", "google"]
2143
+ },
2144
+ "GoogleFontCatalogEntry": {
2145
+ "type": "object",
2146
+ "required": ["family", "category", "subsets", "cached"],
2147
+ "properties": {
2148
+ "cached": {
2149
+ "type": "boolean"
2150
+ },
2151
+ "category": {
2152
+ "type": "string"
2153
+ },
2154
+ "family": {
2155
+ "type": "string"
2156
+ },
2157
+ "subsets": {
2158
+ "type": "array",
2159
+ "items": {
2160
+ "type": "string"
2161
+ }
2162
+ }
2163
+ }
2164
+ },
2165
+ "GoogleFontCatalogResponse": {
2166
+ "type": "object",
2167
+ "required": ["fonts", "recommended"],
2168
+ "properties": {
2169
+ "fonts": {
2170
+ "type": "array",
2171
+ "items": {
2172
+ "$ref": "#/components/schemas/GoogleFontCatalogEntry"
2173
+ }
2174
+ },
2175
+ "recommended": {
2176
+ "type": "array",
2177
+ "items": {
2178
+ "type": "string"
2179
+ }
2180
+ }
2181
+ }
2182
+ },
2183
  "HttpConfig": {
2184
  "type": "object",
2185
  "properties": {
 
2555
  "documentId": {
2556
  "type": ["string", "null"]
2557
  },
 
 
 
2558
  "language": {
2559
  "type": ["string", "null"]
2560
  },
 
2654
  "RenderRequest": {
2655
  "type": "object",
2656
  "properties": {
 
 
 
2657
  "shaderEffect": {
2658
  "oneOf": [
2659
  {
 
3001
  "type": ["string", "null"]
3002
  }
3003
  }
3004
+ },
3005
+ "UpdateDocumentStyleRequest": {
3006
+ "type": "object",
3007
+ "properties": {
3008
+ "defaultFont": {
3009
+ "type": ["string", "null"]
3010
+ }
3011
+ }
3012
  }
3013
  }
3014
  }