Skip to content

Commit 8094075

Browse files
build: add font subsetting for WASM and fix DataSaverState
1 parent 6d7dbaa commit 8094075

9 files changed

Lines changed: 266 additions & 27 deletions

File tree

composeApp/build.gradle.kts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,35 @@ plugins {
1212
alias(libs.plugins.compose.compiler)
1313
}
1414

15+
val subsetFontCommand = if (System.getProperty("os.name").startsWith("Windows", ignoreCase = true)) {
16+
listOf("cmd", "/c", "python", "scripts/subset_wasm_font.py")
17+
} else {
18+
listOf("python3", "scripts/subset_wasm_font.py")
19+
}
20+
21+
val subsetWasmFont by tasks.registering(Exec::class) {
22+
group = "compose"
23+
description = "Generate the wasm subset font from Compose string resources."
24+
workingDir = rootDir
25+
commandLine(subsetFontCommand)
26+
inputs.file(rootProject.file("scripts/font-source/NotoSansSC_VF.ttf"))
27+
inputs.files(
28+
project.file("src/commonMain/composeResources/values/strings.xml"),
29+
project.file("src/commonMain/composeResources/values-zh/strings.xml")
30+
)
31+
outputs.file(project.file("src/wasmJsMain/composeResources/font/NotoSansSC_WasmSubset.ttf"))
32+
}
33+
34+
listOf(
35+
"generateResourceAccessorsForWasmJsMain",
36+
"copyNonXmlValueResourcesForWasmJsMain",
37+
"prepareComposeResourcesTaskForWasmJsMain"
38+
).let { taskNames ->
39+
tasks.matching { it.name in taskNames }.configureEach {
40+
dependsOn(subsetWasmFont)
41+
}
42+
}
43+
1544
kotlin {
1645
targets.configureEach {
1746
compilations.configureEach {
Binary file not shown.

composeApp/src/wasmJsMain/kotlin/com/funny/data_saver/main.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import androidx.compose.ui.platform.LocalFontFamilyResolver
1111
import androidx.compose.ui.text.font.FontFamily
1212
import androidx.compose.ui.window.ComposeViewport
1313
import com.funny.data_saver.ui.App
14-
import composedatasaver.composeapp.generated.resources.NotoSansSC_VF
14+
import composedatasaver.composeapp.generated.resources.NotoSansSC_WasmSubset
1515
import composedatasaver.composeapp.generated.resources.Res
1616
import kotlinx.browser.document
1717
import org.jetbrains.compose.resources.ExperimentalResourceApi
@@ -21,7 +21,7 @@ import org.jetbrains.compose.resources.preloadFont
2121
fun main() {
2222
ComposeViewport(document.body!!) {
2323
val fontResolver = LocalFontFamilyResolver.current
24-
val fallbackFont by preloadFont(Res.font.NotoSansSC_VF)
24+
val fallbackFont by preloadFont(Res.font.NotoSansSC_WasmSubset)
2525
var fallbackFontReady by remember { mutableStateOf(false) }
2626

2727
LaunchedEffect(fallbackFont) {

composeApp/src/wasmJsMain/resources/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<link type="text/css" rel="stylesheet" href="styles.css">
88
<link
99
rel="preload"
10-
href="./composeResources/composedatasaver.composeapp.generated.resources/font/NotoSansSC_VF.ttf"
10+
href="./composeResources/composedatasaver.composeapp.generated.resources/font/NotoSansSC_WasmSubset.ttf"
1111
as="fetch"
1212
type="font/ttf"
1313
crossorigin

data-saver-core/src/commonMain/kotlin/com/funny/data_saver/core/DataSaverState.kt

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -192,24 +192,24 @@ inline fun <reified T> rememberDataSaverState(
192192
): DataSaverMutableState<T> {
193193
val saverInterface = getLocalDataSaverInterface()
194194
val valueType = typeOf<T>()
195-
var state: DataSaverMutableState<T>? = null
196-
val restorer by remember(initialValue, typeConverter) {
197-
lazy {
198-
resolveRestorer(typeConverter, valueType, initialValue)
199-
}
195+
val restorer = remember(initialValue, typeConverter, valueType) {
196+
resolveRestorer(typeConverter, valueType, initialValue)
197+
}
198+
val state = remember(saverInterface, key, initialValue, typeConverter, savePolicy, async, coroutineScope) {
199+
mutableDataSaverStateOf(saverInterface, key, initialValue, typeConverter, savePolicy, async, coroutineScope)
200200
}
201201

202-
LaunchedEffect(key1 = senseExternalDataChange) {
203-
if (!senseExternalDataChange || state == null) return@LaunchedEffect
202+
LaunchedEffect(saverInterface, key, senseExternalDataChange, state, restorer) {
203+
if (!senseExternalDataChange) return@LaunchedEffect
204204
if (!saverInterface.senseExternalDataChange) {
205205
Log.e("ComposeDataSaver", "to enable senseExternalDataChange, you should set `senseExternalDataChange` to true in DataSaverInterface")
206206
return@LaunchedEffect
207207
}
208208
saverInterface.externalDataChangedFlow?.collect { pair ->
209209
val (k, v) = pair
210210
Log.i("ComposeDataSaver", "externalDataChangedFlow: $key -> $v")
211-
if (k == key && v != state?.value) {
212-
val d = if (v != null) {
211+
if (k == key) {
212+
val restoredValue = if (v != null) {
213213
if (v is String) {
214214
val restore = restorer ?: unsupportedType(initialValue, "restore")
215215
restore(v) as T
@@ -219,29 +219,27 @@ inline fun <reified T> rememberDataSaverState(
219219
} else {
220220
// if the value is null
221221
// and the type is nullable
222-
if (typeOf<T>().isMarkedNullable) v as T
222+
if (valueType.isMarkedNullable) v as T
223223
else initialValue
224224
}
225-
// to avoid duplicate save
226-
state?.setValueWithoutSave(d)
225+
if (restoredValue != state.value) {
226+
// to avoid duplicate save
227+
state.setValueWithoutSave(restoredValue)
228+
}
227229
}
228230
}
229231
}
230232

231-
DisposableEffect(key, savePolicy) {
233+
DisposableEffect(key, savePolicy, state) {
232234
onDispose {
233235
Log.i("ComposeDataSaver", "rememberDataSaverState: state of key=\"$key\" onDisposed!")
234-
if (savePolicy == SavePolicy.DISPOSED && state != null && state!!.valueChangedSinceInit()) {
235-
state!!.saveData()
236+
if (savePolicy == SavePolicy.DISPOSED && state.valueChangedSinceInit()) {
237+
state.saveData()
236238
}
237239
}
238240
}
239241

240-
return remember(saverInterface, key, initialValue, typeConverter, savePolicy, async, coroutineScope) {
241-
mutableDataSaverStateOf(saverInterface, key, initialValue, typeConverter, savePolicy, async, coroutineScope).also {
242-
state = it
243-
}
244-
}
242+
return state
245243
}
246244

247245
/**

data-saver-core/src/wasmJsMain/kotlin/com/funny/data_saver/core/DataSaverLocalStorage.kt

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.funny.data_saver.core
22

33
import kotlinx.browser.localStorage
4+
import kotlinx.browser.window
5+
import kotlinx.coroutines.flow.MutableSharedFlow
6+
import org.w3c.dom.StorageEvent
47
import org.w3c.dom.get
58
import org.w3c.dom.set
69

@@ -12,10 +15,40 @@ open class DataSaverLocalStorage(
1215
private val keyPrefix: String = "DataSaver_",
1316
senseExternalDataChange: Boolean = false
1417
) : DataSaverInterface(senseExternalDataChange) {
18+
private companion object {
19+
private val sharedFlows = mutableMapOf<String, MutableSharedFlow<Pair<String, Any?>>>()
20+
private var storageListenerRegistered = false
21+
22+
private fun flowForPrefix(keyPrefix: String): MutableSharedFlow<Pair<String, Any?>> {
23+
return sharedFlows.getOrPut(keyPrefix) {
24+
MutableSharedFlow(replay = 1)
25+
}
26+
}
27+
28+
private fun ensureStorageListener() {
29+
if (storageListenerRegistered) return
30+
storageListenerRegistered = true
31+
window.addEventListener("storage") { event ->
32+
val storageEvent = event as? StorageEvent ?: return@addEventListener
33+
val changedKey = storageEvent.key ?: return@addEventListener
34+
sharedFlows.forEach { (prefix, flow) ->
35+
if (changedKey.startsWith(prefix)) {
36+
flow.tryEmit(changedKey.removePrefix(prefix) to storageEvent.newValue)
37+
}
38+
}
39+
}
40+
}
41+
}
42+
43+
init {
44+
if (senseExternalDataChange) {
45+
externalDataChangedFlow = flowForPrefix(keyPrefix)
46+
ensureStorageListener()
47+
}
48+
}
1549

16-
// localStorage doesn't support listener by default, so we manually notify the listener
1750
private fun notifyExternalDataChanged(key: String, value: Any?) {
18-
if (senseExternalDataChange) externalDataChangedFlow?.tryEmit(key to value)
51+
externalDataChangedFlow?.tryEmit(key to value)
1952
}
2053

2154
private fun getPrefixedKey(key: String) = keyPrefix + key
@@ -72,4 +105,4 @@ open class DataSaverLocalStorage(
72105

73106
val DefaultDataSaverLocalStorage by lazy(LazyThreadSafetyMode.PUBLICATION) {
74107
DataSaverLocalStorage()
75-
}
108+
}

data-saver-core/src/wasmJsTest/kotlin/com/funny/data_saver/core/DataSaverLocalStorageTest.kt

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
package com.funny.data_saver.core
22

3+
import kotlinx.coroutines.async
4+
import kotlinx.coroutines.flow.first
5+
import kotlinx.coroutines.test.runTest
6+
import kotlinx.coroutines.withTimeout
37
import kotlin.test.AfterTest
48
import kotlin.test.BeforeTest
59
import kotlin.test.Test
@@ -272,4 +276,31 @@ class DataSaverLocalStorageTest : DataSaverInterfaceTest() {
272276
// 清理
273277
dataSaverLocalStorage.remove(key)
274278
}
275-
}
279+
280+
@Test
281+
fun testExternalChangeFlowSharedAcrossInstances() = runTest {
282+
val first = DataSaverLocalStorage(
283+
keyPrefix = "shared_",
284+
senseExternalDataChange = true
285+
)
286+
val second = DataSaverLocalStorage(
287+
keyPrefix = "shared_",
288+
senseExternalDataChange = true
289+
)
290+
val key = "shared_key"
291+
val value = "shared_value"
292+
293+
val event = async {
294+
withTimeout(5_000) {
295+
second.externalDataChangedFlow!!.first { (changedKey, changedValue) ->
296+
changedKey == key && changedValue == value
297+
}
298+
}
299+
}
300+
301+
first.saveData(key, value)
302+
303+
assertEquals(key to value, event.await())
304+
second.remove(key)
305+
}
306+
}

composeApp/src/commonMain/composeResources/font/NotoSansSC_VF.ttf renamed to scripts/font-source/NotoSansSC_VF.ttf

File renamed without changes.

scripts/subset_wasm_font.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import sys
5+
import xml.etree.ElementTree as ET
6+
from pathlib import Path
7+
8+
try:
9+
from fontTools import subset
10+
from fontTools.ttLib import TTFont
11+
except ImportError as exc:
12+
raise SystemExit(
13+
"Missing dependency: fonttools. Install it with `python -m pip install fonttools`."
14+
) from exc
15+
16+
17+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
18+
DEFAULT_SOURCE_FONT = PROJECT_ROOT / "scripts" / "font-source" / "NotoSansSC_VF.ttf"
19+
DEFAULT_OUTPUT_FONT = (
20+
PROJECT_ROOT
21+
/ "composeApp"
22+
/ "src"
23+
/ "wasmJsMain"
24+
/ "composeResources"
25+
/ "font"
26+
/ "NotoSansSC_WasmSubset.ttf"
27+
)
28+
DEFAULT_STRING_FILES = (
29+
PROJECT_ROOT / "composeApp" / "src" / "commonMain" / "composeResources" / "values" / "strings.xml",
30+
PROJECT_ROOT / "composeApp" / "src" / "commonMain" / "composeResources" / "values-zh" / "strings.xml",
31+
)
32+
ASCII_PRINTABLE = "".join(chr(code_point) for code_point in range(0x20, 0x7F))
33+
34+
35+
def parse_args() -> argparse.Namespace:
36+
parser = argparse.ArgumentParser(
37+
description="Generate a wasm-only subset font from Compose string resources."
38+
)
39+
parser.add_argument(
40+
"--source-font",
41+
type=Path,
42+
default=DEFAULT_SOURCE_FONT,
43+
help="Path to the original font file.",
44+
)
45+
parser.add_argument(
46+
"--output-font",
47+
type=Path,
48+
default=DEFAULT_OUTPUT_FONT,
49+
help="Path of the generated subset font.",
50+
)
51+
parser.add_argument(
52+
"--strings",
53+
type=Path,
54+
nargs="+",
55+
default=list(DEFAULT_STRING_FILES),
56+
help="String resource XML files to scan.",
57+
)
58+
parser.add_argument(
59+
"--extra-chars",
60+
default="",
61+
help="Extra characters to force-include.",
62+
)
63+
return parser.parse_args()
64+
65+
66+
def extract_strings(xml_file: Path) -> list[str]:
67+
tree = ET.parse(xml_file)
68+
root = tree.getroot()
69+
texts: list[str] = []
70+
for element in root.iter():
71+
if element.tag not in {"string", "item"}:
72+
continue
73+
if element.text:
74+
texts.append(element.text)
75+
return texts
76+
77+
78+
def build_charset(string_files: list[Path], extra_chars: str) -> str:
79+
characters = set(ASCII_PRINTABLE)
80+
for string_file in string_files:
81+
for text in extract_strings(string_file):
82+
characters.update(text)
83+
characters.update(extra_chars)
84+
return "".join(sorted(characters))
85+
86+
87+
def subset_font(source_font: Path, output_font: Path, text: str) -> None:
88+
options = subset.Options()
89+
options.ignore_missing_glyphs = True
90+
options.layout_features = ["*"]
91+
options.name_IDs = ["*"]
92+
options.name_languages = ["*"]
93+
options.notdef_outline = True
94+
options.recommended_glyphs = True
95+
options.symbol_cmap = True
96+
options.legacy_cmap = True
97+
options.hinting = False
98+
options.desubroutinize = True
99+
100+
font = TTFont(source_font)
101+
subsetter = subset.Subsetter(options=options)
102+
subsetter.populate(text=text)
103+
subsetter.subset(font)
104+
105+
output_font.parent.mkdir(parents=True, exist_ok=True)
106+
font.save(output_font)
107+
108+
109+
def format_size(size: int) -> str:
110+
units = ("B", "KB", "MB", "GB")
111+
current = float(size)
112+
for unit in units:
113+
if current < 1024 or unit == units[-1]:
114+
return f"{current:.2f} {unit}"
115+
current /= 1024
116+
return f"{size} B"
117+
118+
119+
def main() -> int:
120+
args = parse_args()
121+
source_font = args.source_font.resolve()
122+
output_font = args.output_font.resolve()
123+
string_files = [path.resolve() for path in args.strings]
124+
125+
missing_files = [path for path in [source_font, *string_files] if not path.exists()]
126+
if missing_files:
127+
missing = "\n".join(f" - {path}" for path in missing_files)
128+
print(f"Missing required file(s):\n{missing}", file=sys.stderr)
129+
return 1
130+
131+
charset = build_charset(string_files, args.extra_chars)
132+
subset_font(source_font, output_font, charset)
133+
134+
source_size = source_font.stat().st_size
135+
output_size = output_font.stat().st_size
136+
reduction = 0.0 if source_size == 0 else (1 - output_size / source_size) * 100
137+
138+
print(f"Source font : {source_font}")
139+
print(f"Output font : {output_font}")
140+
print(f"Glyph chars : {len(set(charset))}")
141+
print(f"Source size : {format_size(source_size)}")
142+
print(f"Output size : {format_size(output_size)}")
143+
print(f"Reduction : {reduction:.2f}%")
144+
return 0
145+
146+
147+
if __name__ == "__main__":
148+
raise SystemExit(main())

0 commit comments

Comments
 (0)