diff --git a/Cargo.lock b/Cargo.lock index d985e192..09972bc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1604,6 +1604,20 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -2218,7 +2232,7 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09af756abf2663ff667f496cfd739481dea10f190b7fd75a7a9ab9bffd444bd7" dependencies = [ - "dashmap", + "dashmap 5.5.3", ] [[package]] @@ -2559,7 +2573,7 @@ dependencies = [ "boxed_error", "capacity_builder", "chrono", - "dashmap", + "dashmap 5.5.3", "deno_cache_dir", "deno_config", "deno_error", @@ -3783,6 +3797,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix 1.1.3", + "tokio", + "windows-sys 0.59.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -5771,7 +5796,7 @@ dependencies = [ "async-trait", "boxed_error", "capacity_builder", - "dashmap", + "dashmap 5.5.3", "deno_error", "deno_maybe_sync", "deno_media_type", @@ -8596,7 +8621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49b3eba5fd24fb4cf7b5092474711a40e47e4cff973b839a7c1c69c1557b272d" dependencies = [ "bytes-str", - "dashmap", + "dashmap 5.5.3", "indexmap 2.13.0", "once_cell", "par-core", @@ -9782,6 +9807,24 @@ dependencies = [ "vl-convert-canvas2d", ] +[[package]] +name = "vl-convert-fontsource" +version = "2.0.0-rc1" +dependencies = [ + "backon", + "dashmap 6.1.0", + "dirs", + "filetime", + "fs4", + "log", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "vl-convert-python" version = "2.0.0-rc1" @@ -9836,6 +9879,7 @@ dependencies = [ "usvg", "vl-convert-canvas2d", "vl-convert-canvas2d-deno", + "vl-convert-fontsource", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f0ce7c57..46653777 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,8 @@ members = [ "vl-convert-python", "vl-convert-vendor", "vl-convert-canvas2d", - "vl-convert-canvas2d-deno" + "vl-convert-canvas2d-deno", + "vl-convert-fontsource", ] [profile.release] @@ -33,10 +34,14 @@ deno_ast = { version = "0.52.0", features = ["bundler", "codegen", "transforms", deno_semver = "0.9.1" sys_traits = { version = "0.1.22", features = ["real", "libc"] } +dashmap = "6" dircpy = "0.3" +dirs = "6" dssim = "3.2.4" env_logger = "0.11.8" +filetime = "0.2" fontdb = { version = "0.23.0", features = ["fontconfig"] } +fs4 = { version = "0.13", features = ["tokio", "sync"] } futures = "0.3.30" futures-util = "0.3.30" image = { version = "0.25", default-features = false, features = ["jpeg"] } diff --git a/thirdparty_rust.yaml b/thirdparty_rust.yaml index b210d407..5082ca19 100644 --- a/thirdparty_rust.yaml +++ b/thirdparty_rust.yaml @@ -1,4 +1,4 @@ -root_name: vl-convert-rs, vl-convert-canvas2d, vl-convert-canvas2d-deno, vl-convert, vl-convert-python, vl-convert-vendor +root_name: vl-convert-rs, vl-convert-canvas2d, vl-convert-canvas2d-deno, vl-convert-fontsource, vl-convert, vl-convert-python, vl-convert-vendor third_party_libraries: - package_name: adler2 package_version: 2.0.1 @@ -18317,6 +18317,34 @@ third_party_libraries: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +- package_name: dashmap + package_version: 6.1.0 + repository: https://github.com/xacrimon/dashmap + license: MIT + licenses: + - license: MIT + text: | + MIT License + + Copyright (c) 2019 Acrimon + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -25590,6 +25618,40 @@ third_party_libraries: DEALINGS IN THE SOFTWARE. - license: Apache-2.0 text: " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" +- package_name: fs4 + package_version: 0.13.1 + repository: https://github.com/al8n/fs4-rs + license: MIT OR Apache-2.0 + licenses: + - license: MIT + text: | + Copyright (c) 2015 The Rust Project Developers + + Permission is hereby granted, free of charge, to any + person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the + Software without restriction, including without + limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software + is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice + shall be included in all copies or substantial portions + of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + - license: Apache-2.0 + text: " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" - package_name: fsevent-sys package_version: 4.1.0 repository: https://github.com/octplane/fsevent-rust/tree/master/fsevent-sys diff --git a/vl-convert-fontsource/Cargo.toml b/vl-convert-fontsource/Cargo.toml new file mode 100644 index 00000000..150a87b4 --- /dev/null +++ b/vl-convert-fontsource/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "vl-convert-fontsource" +version = "2.0.0-rc1" +edition = "2021" +description = "Fontsource font downloading and caching for vl-convert" +license = "BSD-3-Clause" +repository = "https://github.com/vega/vl-convert" + +[dependencies] +backon = { workspace = true } +dashmap = { workspace = true } +dirs = { workspace = true } +filetime = { workspace = true } +fs4 = { workspace = true } +log = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = "2" +tokio = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/vl-convert-fontsource/src/cache.rs b/vl-convert-fontsource/src/cache.rs new file mode 100644 index 00000000..c68ec6bf --- /dev/null +++ b/vl-convert-fontsource/src/cache.rs @@ -0,0 +1,985 @@ +use crate::error::FontsourceError; +use crate::types::{family_to_id, FetchOutcome, FontsourceFont, FontsourceMarker, MARKER_FILENAME}; +use backon::{ExponentialBuilder, Retryable}; +use dashmap::DashMap; +use filetime::FileTime; +use fs4::fs_std::FileExt; +use log::{debug, info, warn}; +use reqwest::StatusCode; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +const FONTSOURCE_API: &str = "https://api.fontsource.org/v1/fonts"; + +/// A concurrent, disk-backed cache for Fontsource font files. +/// +/// Downloads TTF files from the Fontsource API and caches them on disk. +/// Thread-safe: all methods take `&self` and use file locks + per-font +/// mutexes for coordination. +pub struct FontsourceCache { + cache_dir: PathBuf, + client: reqwest::Client, + max_cache_bytes: AtomicU64, // 0 = unbounded + download_gates: DashMap>>, + known_fonts: DashMap, +} + +impl FontsourceCache { + /// Create a new `FontsourceCache`. + /// + /// # Arguments + /// * `cache_dir` - Directory for cached fonts. Defaults to the platform + /// cache directory under `vl-convert/fonts`. + /// * `max_cache_bytes` - Optional maximum cache size. `None` means unbounded. + pub fn new( + cache_dir: Option, + max_cache_bytes: Option, + ) -> Result { + let cache_dir = match cache_dir { + Some(dir) => dir, + None => dirs::cache_dir() + .map(|d| d.join("vl-convert").join("fonts")) + .ok_or(FontsourceError::NoCacheDir)?, + }; + + let client = reqwest::Client::builder() + .user_agent("vl-convert") + .build() + .map_err(FontsourceError::Http)?; + + Ok(Self { + cache_dir, + client, + max_cache_bytes: AtomicU64::new(max_cache_bytes.unwrap_or(0)), + download_gates: DashMap::new(), + known_fonts: DashMap::new(), + }) + } + + /// Set the maximum cache size in bytes. 0 means unbounded. + pub fn set_max_cache_bytes(&self, max_bytes: u64) { + self.max_cache_bytes.store(max_bytes, Ordering::Relaxed); + } + + /// Get the maximum cache size in bytes. 0 means unbounded. + pub fn max_cache_bytes(&self) -> u64 { + self.max_cache_bytes.load(Ordering::Relaxed) + } + + /// Return the on-disk directory for a given font ID. + pub fn font_dir(&self, font_id: &str) -> PathBuf { + self.cache_dir.join(font_id) + } + + /// Fetch font metadata from the Fontsource API. + /// + /// Maps HTTP 404 to [`FontsourceError::FontNotFound`]. Retries transient + /// errors with exponential backoff. + pub async fn fetch_metadata(&self, font_id: &str) -> Result { + let url = format!("{}/{}", FONTSOURCE_API, font_id); + let client = self.client.clone(); + let font_id_owned = font_id.to_string(); + + let response = (|| { + let client = client.clone(); + let url = url.clone(); + async move { + let resp = client.get(&url).send().await?.error_for_status()?; + Ok::<_, reqwest::Error>(resp) + } + }) + .retry(ExponentialBuilder::default()) + .when(|e: &reqwest::Error| { + // Don't retry 404s + if let Some(status) = e.status() { + if status == StatusCode::NOT_FOUND { + return false; + } + // Retry server errors and rate limiting + return status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS; + } + // Retry connection/timeout errors + true + }) + .await; + + match response { + Ok(resp) => { + let bytes = resp.bytes().await?; + let font: FontsourceFont = serde_json::from_slice(&bytes)?; + Ok(font) + } + Err(e) => { + if e.status() == Some(StatusCode::NOT_FOUND) { + Err(FontsourceError::FontNotFound(font_id_owned)) + } else { + Err(FontsourceError::Http(e)) + } + } + } + } + + /// Download a TTF file from `url` to `path`. + /// + /// If `!force` and `path` already exists, returns immediately. + /// Downloads to a temporary file first, then atomically renames. + /// Retries transient errors with exponential backoff. + async fn download_ttf( + &self, + url: &str, + path: &Path, + force: bool, + ) -> Result<(), FontsourceError> { + if !force && path.exists() { + return Ok(()); + } + + let client = self.client.clone(); + let url_owned = url.to_string(); + let path_owned = path.to_path_buf(); + + (|| { + let client = client.clone(); + let url = url_owned.clone(); + let path = path_owned.clone(); + async move { + let bytes = client + .get(&url) + .send() + .await? + .error_for_status()? + .bytes() + .await?; + + // Write to a unique temp file in the same directory + let parent = path.parent().unwrap_or(&path); + let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("font"); + let temp_name = format!( + "{}.{}.{}.tmp", + file_name, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() + ); + let temp_path = parent.join(&temp_name); + + tokio::fs::write(&temp_path, &bytes) + .await + .inspect_err(|_e| { + // Clean up temp file on write error + let _ = std::fs::remove_file(&temp_path); + })?; + + if let Err(e) = atomic_rename(&temp_path, &path) { + // Clean up temp file on rename error + let _ = std::fs::remove_file(&temp_path); + return Err(FontsourceError::Io(e)); + } + + Ok::<_, FontsourceError>(()) + } + }) + .retry(ExponentialBuilder::default()) + .when(|e: &FontsourceError| { + matches!(e, FontsourceError::Http(re) if { + if let Some(status) = re.status() { + status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS + } else { + // Retry connection/timeout errors + true + } + }) + }) + .await + } + + /// Fetch a font by family name, downloading if not already cached. + /// + /// Returns a [`FetchOutcome`] indicating whether a download occurred + /// and the path to the font directory. + /// + /// # Fast path + /// If the `.fontsource.json` marker exists and at least one `.ttf` file + /// is present, the font is considered cached. The marker's mtime is + /// touched for LRU bookkeeping. + /// + /// # Slow path + /// Fetches metadata from the Fontsource API, downloads all TTF files + /// (all subsets, weights, and styles), then writes the marker. + pub async fn fetch(&self, family: &str) -> Result { + let font_id = family_to_id(family) + .ok_or_else(|| FontsourceError::InvalidFontId(family.to_string()))?; + let font_dir = self.font_dir(&font_id); + + // ---- Fast path: marker exists + TTFs present ---- + if self.check_cache_hit(&font_dir).await? { + let font_type = self.read_marker(&font_dir).await.and_then(|m| m.font_type); + return Ok(FetchOutcome { + path: font_dir, + font_id, + downloaded: false, + font_type, + }); + } + + // ---- Slow path: acquire per-font gate ---- + let gate = self + .download_gates + .entry(font_id.clone()) + .or_default() + .clone(); + let _guard = gate.lock().await; + + // Re-check after acquiring gate (another task may have completed the download) + if self.check_cache_hit(&font_dir).await? { + let font_type = self.read_marker(&font_dir).await.and_then(|m| m.font_type); + return Ok(FetchOutcome { + path: font_dir, + font_id, + downloaded: false, + font_type, + }); + } + + // Acquire shared file lock for the mutation sequence + let font_type = self.do_download(&font_id, family, &font_dir, false).await?; + + Ok(FetchOutcome { + path: font_dir, + font_id, + downloaded: true, + font_type: Some(font_type), + }) + } + + /// Re-fetch a font, forcing re-download even if cached. + /// + /// Deletes existing marker and TTF files, then re-downloads everything. + /// File deletion and re-download both happen under an exclusive cache lock + /// inside `do_download` (when `force` is true) to prevent races with + /// concurrent registration, eviction, or clear operations. + pub async fn refetch(&self, family: &str) -> Result { + let font_id = family_to_id(family) + .ok_or_else(|| FontsourceError::InvalidFontId(family.to_string()))?; + let font_dir = self.font_dir(&font_id); + + // Acquire per-font gate + let gate = self + .download_gates + .entry(font_id.clone()) + .or_default() + .clone(); + let _guard = gate.lock().await; + + // Delete + re-download under exclusive cache lock (force=true triggers delete) + let font_type = self.do_download(&font_id, family, &font_dir, true).await?; + + Ok(FetchOutcome { + path: font_dir, + font_id, + downloaded: true, + font_type: Some(font_type), + }) + } + + /// Clear cached files for a specific font family. + /// + /// Acquires an exclusive file lock to prevent concurrent reads/writes. + pub fn clear(&self, family: &str) -> Result<(), FontsourceError> { + let font_id = family_to_id(family) + .ok_or_else(|| FontsourceError::InvalidFontId(family.to_string()))?; + let font_dir = self.font_dir(&font_id); + + self.with_exclusive_lock(|| { + if font_dir.exists() { + std::fs::remove_dir_all(&font_dir)?; + } + Ok(()) + }) + } + + /// Clear the entire font cache. + /// + /// Acquires an exclusive file lock to prevent concurrent reads/writes. + pub fn clear_all(&self) -> Result<(), FontsourceError> { + self.with_exclusive_lock(|| { + if self.cache_dir.exists() { + // Remove all subdirectories but preserve the lock file + let entries = std::fs::read_dir(&self.cache_dir)?; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + std::fs::remove_dir_all(&path)?; + } else if path.file_name().and_then(|n| n.to_str()) != Some(".cache-lock") { + std::fs::remove_file(&path)?; + } + } + } + Ok(()) + }) + } + + /// Run a closure while holding a shared file lock on `.cache-lock`. + pub fn with_cache_lock(&self, f: F) -> Result + where + F: FnOnce() -> R, + { + std::fs::create_dir_all(&self.cache_dir)?; + let lock_path = self.cache_dir.join(".cache-lock"); + let lock_file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&lock_path)?; + lock_file.lock_shared()?; + let result = f(); + // lock released when lock_file is dropped + Ok(result) + } + + /// Check whether a font ID is known to Fontsource. + /// + /// Results are cached in-memory. Transport errors are propagated without + /// caching (so subsequent calls can retry). + pub async fn is_known_font(&self, font_id: &str) -> Result { + // Check in-memory cache first + if let Some(entry) = self.known_fonts.get(font_id) { + return Ok(*entry); + } + + let url = format!("{}/{}", FONTSOURCE_API, font_id); + let response = self.client.get(&url).send().await?; + + match response.status() { + StatusCode::OK => { + self.known_fonts.insert(font_id.to_string(), true); + Ok(true) + } + StatusCode::NOT_FOUND => { + self.known_fonts.insert(font_id.to_string(), false); + Ok(false) + } + _status => { + // Transport/server error: propagate without caching + Err(FontsourceError::Http( + response.error_for_status().unwrap_err(), + )) + } + } + } + + /// Calculate the total size of all cached files in bytes. + pub fn calculate_cache_size_bytes(&self) -> Result { + let mut total: u64 = 0; + if !self.cache_dir.exists() { + return Ok(0); + } + + let entries = std::fs::read_dir(&self.cache_dir)?; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + let sub_entries = std::fs::read_dir(&path)?; + for sub_entry in sub_entries { + let sub_entry = sub_entry?; + if sub_entry.path().is_file() { + total += sub_entry.metadata()?.len(); + } + } + } + } + + Ok(total) + } + + /// Evict least-recently-used fonts until the cache size is at or below + /// `target_bytes`. + /// + /// Acquires an exclusive file lock. Fonts in `exempt` are never evicted + /// (used to protect fonts just downloaded in the current batch). + pub fn evict_lru_until_size( + &self, + target_bytes: u64, + exempt: &HashSet, + ) -> Result<(), FontsourceError> { + self.with_exclusive_lock(|| { + if !self.cache_dir.exists() { + return Ok(()); + } + + // Collect font directories with their sizes and mtime + let mut font_entries: Vec<(String, PathBuf, u64, std::time::SystemTime)> = Vec::new(); + let mut total_size: u64 = 0; + + let dir_entries = std::fs::read_dir(&self.cache_dir)?; + for entry in dir_entries { + let entry = entry?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let font_id = match path.file_name().and_then(|n| n.to_str()) { + Some(name) => name.to_string(), + None => continue, + }; + + // Calculate directory size + let mut dir_size: u64 = 0; + let sub_entries = std::fs::read_dir(&path)?; + for sub_entry in sub_entries { + let sub_entry = sub_entry?; + if sub_entry.path().is_file() { + dir_size += sub_entry.metadata()?.len(); + } + } + + // Get mtime of .fontsource.json marker (LRU key) + let marker_path = path.join(MARKER_FILENAME); + let mtime = if marker_path.exists() { + marker_path + .metadata()? + .modified() + .unwrap_or(std::time::UNIX_EPOCH) + } else { + std::time::UNIX_EPOCH + }; + + total_size += dir_size; + font_entries.push((font_id, path, dir_size, mtime)); + } + + if total_size <= target_bytes { + return Ok(()); + } + + // Sort by mtime ascending (oldest first) for LRU eviction + font_entries.sort_by(|a, b| a.3.cmp(&b.3)); + + for (font_id, path, dir_size, _) in &font_entries { + if total_size <= target_bytes { + break; + } + + if exempt.contains(font_id) { + continue; + } + + info!("Evicting cached font '{}' ({} bytes)", font_id, dir_size); + if let Err(e) = std::fs::remove_dir_all(path) { + warn!("Failed to evict font '{}': {}", font_id, e); + continue; + } + + total_size = total_size.saturating_sub(*dir_size); + } + + if total_size > target_bytes { + warn!( + "Cache size ({} bytes) still exceeds target ({} bytes) \ + after evicting all non-exempt fonts", + total_size, target_bytes + ); + } + + Ok(()) + }) + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + /// Check if the cache has a valid entry for the given font directory. + /// + /// Valid means: marker file exists AND at least one `.ttf` file is present. + /// On cache hit, touches the marker mtime for LRU bookkeeping. + async fn check_cache_hit(&self, font_dir: &Path) -> Result { + let marker_path = font_dir.join(MARKER_FILENAME); + + if !marker_path.exists() { + return Ok(false); + } + + // Acquire shared lock to verify TTF files + let lock_path = self.cache_dir.join(".cache-lock"); + std::fs::create_dir_all(&self.cache_dir)?; + + use fs4::tokio::AsyncFileExt; + let lock_file = tokio::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&lock_path) + .await?; + lock_file.lock_shared()?; + + let has_ttf = self.has_ttf_files(font_dir).await; + + // lock released when lock_file is dropped + drop(lock_file); + + if has_ttf { + // Touch marker mtime for LRU bookkeeping + if let Err(e) = filetime::set_file_mtime(&marker_path, FileTime::now()) { + warn!( + "Failed to touch marker mtime for {}: {}", + marker_path.display(), + e + ); + } + Ok(true) + } else { + // Stale marker: directory exists but no TTFs + debug!( + "Stale cache marker at {} (no TTF files found)", + marker_path.display() + ); + Ok(false) + } + } + + /// Check if a directory contains at least one `.ttf` file. + async fn has_ttf_files(&self, dir: &Path) -> bool { + let mut entries = match tokio::fs::read_dir(dir).await { + Ok(entries) => entries, + Err(_) => return false, + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + if entry + .path() + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("ttf")) + .unwrap_or(false) + { + return true; + } + } + false + } + + /// Read the marker file from a font directory, if it exists. + async fn read_marker(&self, font_dir: &Path) -> Option { + let marker_path = font_dir.join(MARKER_FILENAME); + let data = tokio::fs::read_to_string(&marker_path).await.ok()?; + serde_json::from_str(&data).ok() + } + + /// Perform the full download sequence for a font. + /// + /// When `force` is false (normal fetch), acquires a **shared** file lock + /// so multiple concurrent downloads of different fonts can proceed. + /// + /// When `force` is true (refetch), acquires an **exclusive** file lock + /// to prevent readers (e.g. registration via `with_cache_lock`) from + /// seeing a partially-deleted font directory. + /// + /// Returns the `font_type` string (`"google"` or `"other"`) from the API. + async fn do_download( + &self, + font_id: &str, + family: &str, + font_dir: &Path, + force: bool, + ) -> Result { + let lock_path = self.cache_dir.join(".cache-lock"); + std::fs::create_dir_all(&self.cache_dir)?; + + use fs4::tokio::AsyncFileExt; + let lock_file = tokio::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&lock_path) + .await?; + + if force { + // Exclusive lock: block readers while we delete + re-download + lock_file.lock_exclusive()?; + self.delete_font_files(font_dir).await?; + } else { + // Shared lock: compatible with other downloads and registrations + lock_file.lock_shared()?; + } + + // Create font directory + tokio::fs::create_dir_all(font_dir).await?; + + // Fetch metadata + info!("Fetching metadata for font '{}'", font_id); + let metadata = self.fetch_metadata(font_id).await?; + + // Download all TTF files (all subsets, all weights, all styles) + for (weight_key, styles) in &metadata.variants { + for (style_key, subsets) in styles { + for (subset, urls) in subsets { + if let Some(ref ttf_url) = urls.url.ttf { + let filename = format!("{}-{}-{}.ttf", subset, weight_key, style_key); + let file_path = font_dir.join(&filename); + + debug!("Downloading {}", filename); + self.download_ttf(ttf_url, &file_path, force).await?; + } + } + } + } + + let font_type = metadata.font_type.clone(); + + // Write marker via atomic rename + let marker = FontsourceMarker { + id: font_id.to_string(), + family: family.to_string(), + version: metadata.version.clone(), + fetched_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + font_type: Some(font_type.clone()), + }; + let marker_json = serde_json::to_string_pretty(&marker)?; + let marker_path = font_dir.join(MARKER_FILENAME); + let temp_marker = font_dir.join(format!( + ".fontsource.json.{}.{}.tmp", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() + )); + std::fs::write(&temp_marker, marker_json)?; + atomic_rename(&temp_marker, &marker_path)?; + + info!( + "Font '{}' ({}) cached at {}", + family, + font_id, + font_dir.display() + ); + + // lock released when lock_file is dropped + Ok(font_type) + } + + /// Delete marker and TTF files from a font directory. + async fn delete_font_files(&self, font_dir: &Path) -> Result<(), FontsourceError> { + if !font_dir.exists() { + return Ok(()); + } + + let marker_path = font_dir.join(MARKER_FILENAME); + if marker_path.exists() { + tokio::fs::remove_file(&marker_path).await?; + } + + let mut entries = tokio::fs::read_dir(font_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("ttf")) + .unwrap_or(false) + { + tokio::fs::remove_file(&path).await?; + } + } + + Ok(()) + } + + /// Run a closure while holding an exclusive file lock on `.cache-lock`. + fn with_exclusive_lock(&self, f: F) -> Result + where + F: FnOnce() -> Result, + { + std::fs::create_dir_all(&self.cache_dir)?; + let lock_path = self.cache_dir.join(".cache-lock"); + let lock_file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&lock_path)?; + lock_file.lock_exclusive()?; + // lock_file is held until end of scope, ensuring f() runs under the lock + f() + } +} + +/// Atomically rename `src` to `dst`. +/// +/// If the rename fails with `AlreadyExists`, treats it as success +/// (a concurrent download wrote the same file) and deletes the temp file. +fn atomic_rename(src: &Path, dst: &Path) -> Result<(), std::io::Error> { + match std::fs::rename(src, dst) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + // Concurrent writer already placed the file — clean up our temp + let _ = std::fs::remove_file(src); + Ok(()) + } + Err(e) => Err(e), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + + /// Create a fake font directory with a marker and a TTF file of the given size. + fn create_fake_font(cache_dir: &Path, font_id: &str, ttf_size: usize, age_secs: i64) { + let font_dir = cache_dir.join(font_id); + std::fs::create_dir_all(&font_dir).unwrap(); + + // Write a fake TTF file + let ttf_path = font_dir.join("latin-400-normal.ttf"); + let data = vec![0u8; ttf_size]; + std::fs::write(&ttf_path, &data).unwrap(); + + // Write marker + let marker = FontsourceMarker { + id: font_id.to_string(), + family: font_id.to_string(), + version: "1.0.0".to_string(), + fetched_at: 1000000, + font_type: Some("google".to_string()), + }; + let marker_path = font_dir.join(MARKER_FILENAME); + std::fs::write(&marker_path, serde_json::to_string(&marker).unwrap()).unwrap(); + + // Set marker mtime to control LRU ordering + let base = filetime::FileTime::from_unix_time(1_700_000_000, 0); + let mtime = filetime::FileTime::from_unix_time(1_700_000_000 + age_secs, 0); + filetime::set_file_mtime(&marker_path, mtime).unwrap(); + filetime::set_file_mtime(&ttf_path, base).unwrap(); + } + + #[test] + fn test_calculate_cache_size_empty() { + let tmp = tempfile::tempdir().unwrap(); + let cache = FontsourceCache::new(Some(tmp.path().to_path_buf()), None).unwrap(); + assert_eq!(cache.calculate_cache_size_bytes().unwrap(), 0); + } + + #[test] + fn test_calculate_cache_size() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path(); + + create_fake_font(cache_dir, "roboto", 1000, 0); + create_fake_font(cache_dir, "open-sans", 2000, 10); + + let cache = FontsourceCache::new(Some(cache_dir.to_path_buf()), None).unwrap(); + let size = cache.calculate_cache_size_bytes().unwrap(); + + // Each font has a TTF file + marker file. Marker is ~80-100 bytes JSON. + // TTF sizes: 1000 + 2000 = 3000, plus two markers + assert!(size >= 3000, "Expected at least 3000 bytes, got {}", size); + assert!( + size < 4000, + "Expected less than 4000 bytes (markers are small), got {}", + size + ); + } + + #[test] + fn test_evict_lru_oldest_first() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path(); + + // Create three fonts with different ages (age_secs controls mtime) + // oldest (age_secs=0) → middle (age_secs=100) → newest (age_secs=200) + create_fake_font(cache_dir, "font-old", 1000, 0); + create_fake_font(cache_dir, "font-mid", 1000, 100); + create_fake_font(cache_dir, "font-new", 1000, 200); + + let cache = FontsourceCache::new(Some(cache_dir.to_path_buf()), None).unwrap(); + + // Set target that requires evicting at least one font + // Total is ~3000 + markers, target of 2500 should evict the oldest + let exempt = HashSet::new(); + cache.evict_lru_until_size(2500, &exempt).unwrap(); + + // Oldest font should be evicted + assert!( + !cache_dir.join("font-old").exists(), + "Oldest font should be evicted" + ); + assert!( + cache_dir.join("font-mid").exists(), + "Middle font should remain" + ); + assert!( + cache_dir.join("font-new").exists(), + "Newest font should remain" + ); + } + + #[test] + fn test_evict_respects_exempt_set() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path(); + + create_fake_font(cache_dir, "font-old", 1000, 0); + create_fake_font(cache_dir, "font-mid", 1000, 100); + create_fake_font(cache_dir, "font-new", 1000, 200); + + let cache = FontsourceCache::new(Some(cache_dir.to_path_buf()), None).unwrap(); + + // Exempt the oldest font — eviction should skip it + let mut exempt = HashSet::new(); + exempt.insert("font-old".to_string()); + + // Target requires evicting one font + cache.evict_lru_until_size(2500, &exempt).unwrap(); + + // Oldest is exempt, so middle (next oldest) should be evicted + assert!( + cache_dir.join("font-old").exists(), + "Exempt font should not be evicted" + ); + assert!( + !cache_dir.join("font-mid").exists(), + "Next oldest non-exempt font should be evicted" + ); + assert!( + cache_dir.join("font-new").exists(), + "Newest font should remain" + ); + } + + #[test] + fn test_evict_no_op_when_under_limit() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path(); + + create_fake_font(cache_dir, "roboto", 1000, 0); + + let cache = FontsourceCache::new(Some(cache_dir.to_path_buf()), None).unwrap(); + + // Target is larger than current size — nothing should be evicted + cache + .evict_lru_until_size(1_000_000, &HashSet::new()) + .unwrap(); + + assert!( + cache_dir.join("roboto").exists(), + "Font should remain when under limit" + ); + } + + #[test] + fn test_evict_all_exempt_logs_warning() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path(); + + create_fake_font(cache_dir, "font-a", 2000, 0); + create_fake_font(cache_dir, "font-b", 2000, 100); + + let cache = FontsourceCache::new(Some(cache_dir.to_path_buf()), None).unwrap(); + + // Exempt both fonts, target is tiny — should warn but not crash + let mut exempt = HashSet::new(); + exempt.insert("font-a".to_string()); + exempt.insert("font-b".to_string()); + + // This should not error, just log a warning + cache.evict_lru_until_size(100, &exempt).unwrap(); + + // Both fonts should still exist + assert!(cache_dir.join("font-a").exists()); + assert!(cache_dir.join("font-b").exists()); + } + + #[test] + fn test_atomic_rename_basic() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("src.txt"); + let dst = tmp.path().join("dst.txt"); + std::fs::write(&src, "hello").unwrap(); + + atomic_rename(&src, &dst).unwrap(); + + assert!(!src.exists()); + assert_eq!(std::fs::read_to_string(&dst).unwrap(), "hello"); + } + + #[test] + fn test_atomic_rename_concurrent() { + let tmp = tempfile::tempdir().unwrap(); + let dst = tmp.path().join("target.ttf"); + let num_threads = 8; + + // Create all temp files first + let mut temp_paths = Vec::new(); + for i in 0..num_threads { + let src = tmp.path().join(format!("target.ttf.{}.tmp", i)); + std::fs::write(&src, format!("content-{}", i)).unwrap(); + temp_paths.push(src); + } + + // Spawn threads that all try to rename to the same target + let handles: Vec<_> = temp_paths + .into_iter() + .map(|src| { + let dst = dst.clone(); + thread::spawn(move || atomic_rename(&src, &dst)) + }) + .collect(); + + for handle in handles { + // All should succeed (no errors) + handle.join().unwrap().unwrap(); + } + + // Exactly one file at target + assert!(dst.exists()); + // All temp files should be cleaned up + for i in 0..num_threads { + let src = tmp.path().join(format!("target.ttf.{}.tmp", i)); + assert!(!src.exists(), "Temp file {} should be cleaned up", i); + } + } + + #[test] + fn test_evict_multiple_to_reach_target() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path(); + + // Create 5 fonts, each 1000 bytes, with increasing mtime + for i in 0..5 { + create_fake_font(cache_dir, &format!("font-{}", i), 1000, i * 100); + } + + let cache = FontsourceCache::new(Some(cache_dir.to_path_buf()), None).unwrap(); + + // Target: keep ~2 fonts worth of data (2500 bytes including markers) + cache.evict_lru_until_size(2500, &HashSet::new()).unwrap(); + + // The 3 oldest should be evicted + assert!( + !cache_dir.join("font-0").exists(), + "Oldest should be evicted" + ); + assert!( + !cache_dir.join("font-1").exists(), + "Second oldest should be evicted" + ); + assert!( + !cache_dir.join("font-2").exists(), + "Third oldest should be evicted" + ); + // The 2 newest should remain + assert!(cache_dir.join("font-3").exists(), "Fourth should remain"); + assert!(cache_dir.join("font-4").exists(), "Newest should remain"); + } +} diff --git a/vl-convert-fontsource/src/error.rs b/vl-convert-fontsource/src/error.rs new file mode 100644 index 00000000..354a1998 --- /dev/null +++ b/vl-convert-fontsource/src/error.rs @@ -0,0 +1,22 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum FontsourceError { + #[error("Font not found: \"{0}\"")] + FontNotFound(String), + + #[error("Invalid font ID: \"{0}\". Must match [a-z0-9][a-z0-9_-]*")] + InvalidFontId(String), + + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Failed to determine cache directory")] + NoCacheDir, +} diff --git a/vl-convert-fontsource/src/lib.rs b/vl-convert-fontsource/src/lib.rs new file mode 100644 index 00000000..e28496f1 --- /dev/null +++ b/vl-convert-fontsource/src/lib.rs @@ -0,0 +1,7 @@ +pub mod cache; +pub mod error; +pub mod types; + +pub use cache::FontsourceCache; +pub use error::FontsourceError; +pub use types::{FetchOutcome, FontsourceMarker}; diff --git a/vl-convert-fontsource/src/types.rs b/vl-convert-fontsource/src/types.rs new file mode 100644 index 00000000..6bd18d12 --- /dev/null +++ b/vl-convert-fontsource/src/types.rs @@ -0,0 +1,320 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Marker file written to each font directory after successful download. +pub const MARKER_FILENAME: &str = ".fontsource.json"; + +/// CSS font style. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FontStyle { + Normal, + Italic, +} + +impl FontStyle { + pub fn as_str(&self) -> &'static str { + match self { + FontStyle::Normal => "normal", + FontStyle::Italic => "italic", + } + } +} + +/// A request for a specific weight + style combination. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct VariantRequest { + pub weight: u16, + pub style: FontStyle, +} + +/// Default variants to download: 400/700 normal, 400/700 italic. +pub fn default_variants() -> Vec { + vec![ + VariantRequest { + weight: 400, + style: FontStyle::Normal, + }, + VariantRequest { + weight: 700, + style: FontStyle::Normal, + }, + VariantRequest { + weight: 400, + style: FontStyle::Italic, + }, + VariantRequest { + weight: 700, + style: FontStyle::Italic, + }, + ] +} + +/// A cached TTF file with parsed metadata from its filename. +#[derive(Debug, Clone)] +pub struct CachedFontFile { + pub subset: String, + pub weight: u16, + pub style: FontStyle, +} + +/// Convert a font family name to a Fontsource font ID. +/// +/// Rules: +/// 1. Trim whitespace +/// 2. Lowercase +/// 3. Replace spaces with hyphens +/// +/// Returns `None` if the resulting ID doesn't match `^[a-z0-9][a-z0-9_-]*$`. +pub fn family_to_id(family: &str) -> Option { + let id = family.trim().to_lowercase().replace(' ', "-"); + if is_valid_font_id(&id) { + Some(id) + } else { + None + } +} + +/// Check if a string is a valid Fontsource font ID. +pub fn is_valid_font_id(id: &str) -> bool { + if id.is_empty() { + return false; + } + let bytes = id.as_bytes(); + // First char must be lowercase alphanumeric + if !(bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) { + return false; + } + // Remaining chars must be lowercase alphanumeric, hyphen, or underscore + bytes[1..] + .iter() + .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') +} + +/// Parse a cached TTF filename into its components. +/// +/// Filenames follow the pattern: `{subset}-{weight}-{style}.ttf` +/// Since subsets can contain hyphens (e.g. `latin-ext`), we split from the right. +pub fn parse_cached_filename(filename: &str) -> Option { + let stem = filename.strip_suffix(".ttf")?; + let parts: Vec<&str> = stem.rsplitn(3, '-').collect(); + if parts.len() < 3 { + return None; + } + // rsplitn gives [style, weight, subset] (reversed) + let style_str = parts[0]; + let weight_str = parts[1]; + let subset = parts[2]; + + let style = match style_str { + "normal" => FontStyle::Normal, + "italic" => FontStyle::Italic, + _ => return None, + }; + + let weight: u16 = weight_str.parse().ok()?; + + Some(CachedFontFile { + subset: subset.to_string(), + weight, + style, + }) +} + +/// Top-level response from `GET /v1/fonts/{id}` +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FontsourceFont { + pub id: String, + pub family: String, + pub subsets: Vec, + pub weights: Vec, + pub styles: Vec, + pub version: String, + /// `"google"` or `"other"` — from the Fontsource API `type` field. + #[serde(rename = "type")] + pub font_type: String, + /// weight (string) -> style -> subset -> urls + pub variants: HashMap>>, +} + +#[derive(Debug, Deserialize)] +pub struct FontsourceUrls { + pub url: FontsourceFileUrls, +} + +#[derive(Debug, Deserialize)] +pub struct FontsourceFileUrls { + pub ttf: Option, + pub woff2: Option, + pub woff: Option, +} + +/// Marker data written to `.fontsource.json` in each font directory. +#[derive(Debug, Serialize, Deserialize)] +pub struct FontsourceMarker { + pub id: String, + pub family: String, + pub version: String, + pub fetched_at: u64, // Unix timestamp + /// `"google"` or `"other"`. `None` for markers written before this field existed. + #[serde(default)] + pub font_type: Option, +} + +/// Outcome of a fetch or refetch operation. +#[derive(Debug)] +pub struct FetchOutcome { + /// Path to the font directory. + pub path: std::path::PathBuf, + /// Normalized font ID. + pub font_id: String, + /// `true` if a fresh download occurred, `false` if cache hit. + pub downloaded: bool, + /// `"google"` or `"other"`. `None` for old cached markers without this field. + pub font_type: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_family_to_id() { + assert_eq!(family_to_id("Roboto"), Some("roboto".to_string())); + assert_eq!( + family_to_id("Playfair Display"), + Some("playfair-display".to_string()) + ); + assert_eq!( + family_to_id("IBM Plex Sans"), + Some("ibm-plex-sans".to_string()) + ); + assert_eq!( + family_to_id("Noto Sans JP"), + Some("noto-sans-jp".to_string()) + ); + assert_eq!(family_to_id(" Roboto "), Some("roboto".to_string())); + // Invalid: starts with hyphen + assert_eq!(family_to_id("-invalid"), None); + // Invalid: empty + assert_eq!(family_to_id(""), None); + assert_eq!(family_to_id(" "), None); + } + + #[test] + fn test_is_valid_font_id() { + assert!(is_valid_font_id("roboto")); + assert!(is_valid_font_id("playfair-display")); + assert!(is_valid_font_id("ibm-plex-sans")); + assert!(is_valid_font_id("wf_standard-font")); + assert!(is_valid_font_id("123abc")); + assert!(!is_valid_font_id("")); + assert!(!is_valid_font_id("-starts-with-hyphen")); + assert!(!is_valid_font_id("_starts-with-underscore")); + assert!(!is_valid_font_id("has spaces")); + assert!(!is_valid_font_id("HAS-CAPS")); + } + + #[test] + fn test_parse_cached_filename() { + let f = parse_cached_filename("latin-400-normal.ttf").unwrap(); + assert_eq!(f.subset, "latin"); + assert_eq!(f.weight, 400); + assert_eq!(f.style, FontStyle::Normal); + + let f = parse_cached_filename("latin-ext-700-italic.ttf").unwrap(); + assert_eq!(f.subset, "latin-ext"); + assert_eq!(f.weight, 700); + assert_eq!(f.style, FontStyle::Italic); + + let f = parse_cached_filename("cyrillic-ext-400-normal.ttf").unwrap(); + assert_eq!(f.subset, "cyrillic-ext"); + assert_eq!(f.weight, 400); + assert_eq!(f.style, FontStyle::Normal); + + // Invalid cases + assert!(parse_cached_filename("not-a-ttf.woff2").is_none()); + assert!(parse_cached_filename("400-normal.ttf").is_none()); + assert!(parse_cached_filename("latin-400-bold.ttf").is_none()); + } + + #[test] + fn test_fontsource_font_deserializes_type_field() { + let json = r#"{ + "id": "roboto", + "family": "Roboto", + "subsets": ["latin"], + "weights": [400, 700], + "styles": ["normal", "italic"], + "version": "v30", + "type": "google", + "variants": {} + }"#; + let font: FontsourceFont = serde_json::from_str(json).unwrap(); + assert_eq!(font.font_type, "google"); + } + + #[test] + fn test_fontsource_font_deserializes_other_type() { + let json = r#"{ + "id": "custom-font", + "family": "Custom Font", + "subsets": ["latin"], + "weights": [400], + "styles": ["normal"], + "version": "v1", + "type": "other", + "variants": {} + }"#; + let font: FontsourceFont = serde_json::from_str(json).unwrap(); + assert_eq!(font.font_type, "other"); + } + + #[test] + fn test_fontsource_marker_backward_compat() { + // Old markers don't have font_type — should deserialize with None + let json = r#"{ + "id": "roboto", + "family": "Roboto", + "version": "v30", + "fetched_at": 1700000000 + }"#; + let marker: FontsourceMarker = serde_json::from_str(json).unwrap(); + assert_eq!(marker.font_type, None); + } + + #[test] + fn test_fontsource_marker_with_font_type() { + let json = r#"{ + "id": "roboto", + "family": "Roboto", + "version": "v30", + "fetched_at": 1700000000, + "font_type": "google" + }"#; + let marker: FontsourceMarker = serde_json::from_str(json).unwrap(); + assert_eq!(marker.font_type, Some("google".to_string())); + } + + #[test] + fn test_default_variants() { + let variants = default_variants(); + assert_eq!(variants.len(), 4); + assert!(variants.contains(&VariantRequest { + weight: 400, + style: FontStyle::Normal + })); + assert!(variants.contains(&VariantRequest { + weight: 700, + style: FontStyle::Normal + })); + assert!(variants.contains(&VariantRequest { + weight: 400, + style: FontStyle::Italic + })); + assert!(variants.contains(&VariantRequest { + weight: 700, + style: FontStyle::Italic + })); + } +} diff --git a/vl-convert-fontsource/tests/test_cache.rs b/vl-convert-fontsource/tests/test_cache.rs new file mode 100644 index 00000000..b15e8a37 --- /dev/null +++ b/vl-convert-fontsource/tests/test_cache.rs @@ -0,0 +1,158 @@ +use std::collections::HashSet; +use vl_convert_fontsource::FontsourceCache; + +/// Helper to create a cache in a temp directory. +fn temp_cache() -> (tempfile::TempDir, FontsourceCache) { + let tmp = tempfile::tempdir().unwrap(); + let cache = FontsourceCache::new(Some(tmp.path().to_path_buf()), None).unwrap(); + (tmp, cache) +} + +#[tokio::test] +async fn test_fetch_roboto() { + let (tmp, cache) = temp_cache(); + + // First fetch should download + let outcome = cache.fetch("Roboto").await.unwrap(); + assert!(outcome.downloaded); + assert_eq!(outcome.font_id, "roboto"); + assert!(outcome.path.exists()); + + // Marker should exist + let marker_path = outcome.path.join(".fontsource.json"); + assert!(marker_path.exists()); + + // Should have TTF files + let ttf_count = std::fs::read_dir(&outcome.path) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("ttf")) + .unwrap_or(false) + }) + .count(); + assert!(ttf_count > 0, "Expected at least one TTF file"); + + // Second fetch should be a cache hit + let outcome2 = cache.fetch("Roboto").await.unwrap(); + assert!(!outcome2.downloaded); + assert_eq!(outcome2.font_id, "roboto"); + + drop(cache); + drop(tmp); +} + +#[tokio::test] +async fn test_font_not_found() { + let (_tmp, cache) = temp_cache(); + + let result = cache.fetch("definitely-not-a-real-font-name-xyz").await; + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!( + matches!(err, vl_convert_fontsource::FontsourceError::FontNotFound(_)), + "Expected FontNotFound, got: {:?}", + err + ); +} + +#[tokio::test] +async fn test_is_known_font() { + let (_tmp, cache) = temp_cache(); + + // Roboto should be known + assert!(cache.is_known_font("roboto").await.unwrap()); + + // Nonsense should not be known + assert!(!cache + .is_known_font("definitely-not-a-font-xyz") + .await + .unwrap()); + + // Second call should hit in-memory cache + assert!(cache.is_known_font("roboto").await.unwrap()); +} + +#[tokio::test] +async fn test_fetch_and_refetch() { + let (tmp, cache) = temp_cache(); + + // Initial fetch + let outcome = cache.fetch("Open Sans").await.unwrap(); + assert!(outcome.downloaded); + assert_eq!(outcome.font_id, "open-sans"); + + // Refetch should force re-download + let outcome2 = cache.refetch("Open Sans").await.unwrap(); + assert!(outcome2.downloaded); + + drop(cache); + drop(tmp); +} + +#[tokio::test] +async fn test_eviction_during_fetch() { + let (tmp, cache) = temp_cache(); + + // Fetch a font + let outcome1 = cache.fetch("Roboto").await.unwrap(); + assert!(outcome1.downloaded); + + // Fetch another font + let outcome2 = cache.fetch("Open Sans").await.unwrap(); + assert!(outcome2.downloaded); + + // Calculate current size + let size = cache.calculate_cache_size_bytes().unwrap(); + assert!(size > 0); + + // Set cache limit to just above one font's size (force eviction of one) + // Use half the current size as the limit + let target = size / 2; + + // Evict — oldest (roboto, fetched first) should be evicted + let exempt: HashSet = HashSet::from(["open-sans".to_string()]); + cache.evict_lru_until_size(target, &exempt).unwrap(); + + // Open Sans (exempt) should remain + assert!( + tmp.path().join("open-sans").exists(), + "Exempt font should not be evicted" + ); + + drop(cache); + drop(tmp); +} + +#[tokio::test] +async fn test_parallel_same_font_dedup() { + let (_tmp, cache) = temp_cache(); + let cache = std::sync::Arc::new(cache); + + // Spawn two concurrent fetches for the same font + let cache1 = cache.clone(); + let cache2 = cache.clone(); + + let (r1, r2) = tokio::join!(async move { cache1.fetch("Roboto").await }, async move { + cache2.fetch("Roboto").await + },); + + // Both should succeed + let o1 = r1.unwrap(); + let o2 = r2.unwrap(); + + // At most one should report downloaded=true (the other gets dedup'd) + let download_count = [o1.downloaded, o2.downloaded] + .iter() + .filter(|&&d| d) + .count(); + assert!( + download_count <= 1, + "Expected at most 1 download, got {}", + download_count + ); +} diff --git a/vl-convert-python/src/lib.rs b/vl-convert-python/src/lib.rs index 9cfa2d8d..5350028a 100644 --- a/vl-convert-python/src/lib.rs +++ b/vl-convert-python/src/lib.rs @@ -11,15 +11,17 @@ use std::future::Future; use std::path::PathBuf; use std::str::FromStr; use std::sync::{Arc, RwLock}; +use vl_convert_rs::configure_font_cache as configure_font_cache_rs; use vl_convert_rs::converter::{ - FormatLocale, Renderer, TimeFormatLocale, ValueOrString, VgOpts, VlConverterConfig, VlOpts, - ACCESS_DENIED_MARKER, + FormatLocale, MissingFontsPolicy, Renderer, TimeFormatLocale, ValueOrString, VgOpts, + VlConverterConfig, VlOpts, ACCESS_DENIED_MARKER, }; use vl_convert_rs::module_loader::import_map::{ VlVersion, VEGA_EMBED_VERSION, VEGA_THEMES_VERSION, VEGA_VERSION, VL_VERSIONS, }; use vl_convert_rs::module_loader::{FORMATE_LOCALE_MAP, TIME_FORMATE_LOCALE_MAP}; use vl_convert_rs::serde_json; +use vl_convert_rs::text::install_font as install_font_rs; use vl_convert_rs::text::register_font_directory as register_font_directory_rs; use vl_convert_rs::VlConverter as VlConverterRs; @@ -59,6 +61,12 @@ fn converter_config_json(config: &VlConverterConfig) -> serde_json::Value { .as_ref() .map(|root| root.to_string_lossy().to_string()), "allowed_base_urls": config.allowed_base_urls, + "auto_install_fonts": config.auto_install_fonts, + "missing_fonts": match config.missing_fonts { + MissingFontsPolicy::Fallback => "fallback", + MissingFontsPolicy::Warn => "warn", + MissingFontsPolicy::Error => "error", + }, }) } @@ -70,6 +78,9 @@ struct ConverterConfigOverrides { filesystem_root: Option>, // None => no change, Some(None) => clear, Some(Some(urls)) => set allowed_base_urls: Option>>, + font_cache_size_mb: Option, + auto_install_fonts: Option, + missing_fonts: Option, } fn parse_config_overrides( @@ -128,6 +139,44 @@ fn parse_config_overrides( })?)); } } + "font_cache_size_mb" => { + if !value.is_none() { + overrides.font_cache_size_mb = Some(value.extract::().map_err(|err| { + vl_convert_rs::anyhow::anyhow!( + "Invalid font_cache_size_mb value for configure_converter: {err}" + ) + })?); + } + } + "auto_install_fonts" => { + if !value.is_none() { + overrides.auto_install_fonts = + Some(value.extract::().map_err(|err| { + vl_convert_rs::anyhow::anyhow!( + "Invalid auto_install_fonts value for configure_converter: {err}" + ) + })?); + } + } + "missing_fonts" => { + if !value.is_none() { + let s = value.extract::().map_err(|err| { + vl_convert_rs::anyhow::anyhow!( + "Invalid missing_fonts value for configure_converter: {err}" + ) + })?; + overrides.missing_fonts = Some(match s.as_str() { + "fallback" => MissingFontsPolicy::Fallback, + "warn" => MissingFontsPolicy::Warn, + "error" => MissingFontsPolicy::Error, + _ => { + return Err(vl_convert_rs::anyhow::anyhow!( + "Invalid missing_fonts value: {s}. Expected 'fallback', 'warn', or 'error'" + )); + } + }); + } + } other => { return Err(vl_convert_rs::anyhow::anyhow!( "Unknown configure_converter argument: {other}" @@ -152,6 +201,16 @@ fn apply_config_overrides(config: &mut VlConverterConfig, overrides: ConverterCo if let Some(allowed_base_urls) = overrides.allowed_base_urls { config.allowed_base_urls = allowed_base_urls; } + if let Some(mb) = overrides.font_cache_size_mb { + let bytes = mb.saturating_mul(1024 * 1024); + configure_font_cache_rs(Some(bytes)); + } + if let Some(auto_install) = overrides.auto_install_fonts { + config.auto_install_fonts = auto_install; + } + if let Some(missing_fonts) = overrides.missing_fonts { + config.missing_fonts = missing_fonts; + } } fn configure_converter_with_config_overrides( @@ -1217,6 +1276,22 @@ fn register_font_directory(font_dir: &str) -> PyResult<()> { Ok(()) } +/// Downloads font files from the Fontsource catalog (which includes +/// Google Fonts and other open-source fonts) and registers them for +/// use in subsequent conversions. +#[pyfunction] +#[pyo3(signature = (font_family))] +fn install_font(font_family: &str) -> PyResult<()> { + let font_family = font_family.to_string(); + Python::with_gil(|py| { + py.allow_threads(move || { + PYTHON_RUNTIME + .block_on(async move { install_font_rs(&font_family).await }) + .map_err(|err| PyValueError::new_err(format!("Failed to install font: {}", err))) + }) + }) +} + /// Configure converter options for subsequent requests #[pyfunction] #[pyo3(signature = (**kwargs))] @@ -2104,6 +2179,22 @@ fn register_font_directory_asyncio<'py>( }) } +#[doc = async_variant_doc!("install_font")] +#[pyfunction(name = "install_font")] +#[pyo3(signature = (font_family))] +fn install_font_asyncio<'py>(py: Python<'py>, font_family: &str) -> PyResult> { + let font_family = font_family.to_string(); + future_into_py_object(py, async move { + tokio::task::spawn_blocking(move || { + PYTHON_RUNTIME.block_on(async move { install_font_rs(&font_family).await }) + }) + .await + .map_err(|err| PyValueError::new_err(format!("Task join error: {err}")))? + .map_err(|err| PyValueError::new_err(format!("Failed to install font: {err}")))?; + Python::with_gil(|py| Ok(py.None().into())) + }) +} + #[doc = async_variant_doc!("configure_converter")] #[pyfunction(name = "configure_converter")] #[pyo3(signature = (**kwargs))] @@ -2350,6 +2441,7 @@ fn add_asyncio_submodule(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<() asyncio.add_function(wrap_pyfunction!(svg_to_jpeg_asyncio, &asyncio)?)?; asyncio.add_function(wrap_pyfunction!(svg_to_pdf_asyncio, &asyncio)?)?; asyncio.add_function(wrap_pyfunction!(register_font_directory_asyncio, &asyncio)?)?; + asyncio.add_function(wrap_pyfunction!(install_font_asyncio, &asyncio)?)?; asyncio.add_function(wrap_pyfunction!(configure_converter_asyncio, &asyncio)?)?; asyncio.add_function(wrap_pyfunction!(get_converter_config_asyncio, &asyncio)?)?; asyncio.add_function(wrap_pyfunction!(warm_up_workers_asyncio, &asyncio)?)?; @@ -2392,6 +2484,7 @@ fn vl_convert(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(svg_to_jpeg, m)?)?; m.add_function(wrap_pyfunction!(svg_to_pdf, m)?)?; m.add_function(wrap_pyfunction!(register_font_directory, m)?)?; + m.add_function(wrap_pyfunction!(install_font, m)?)?; m.add_function(wrap_pyfunction!(configure_converter, m)?)?; m.add_function(wrap_pyfunction!(get_converter_config, m)?)?; m.add_function(wrap_pyfunction!(warm_up_workers, m)?)?; diff --git a/vl-convert-python/tests/test_access_policy.py b/vl-convert-python/tests/test_access_policy.py index 36ae62d8..5651c33b 100644 --- a/vl-convert-python/tests/test_access_policy.py +++ b/vl-convert-python/tests/test_access_policy.py @@ -191,6 +191,8 @@ def reset_converter_config(): allow_http_access=True, filesystem_root=None, allowed_base_urls=None, + auto_install_fonts=False, + missing_fonts="fallback", ) try: yield @@ -200,6 +202,8 @@ def reset_converter_config(): allow_http_access=True, filesystem_root=None, allowed_base_urls=None, + auto_install_fonts=False, + missing_fonts="fallback", ) diff --git a/vl-convert-python/tests/test_asyncio.py b/vl-convert-python/tests/test_asyncio.py index 7ca8c5e9..ba97b05d 100644 --- a/vl-convert-python/tests/test_asyncio.py +++ b/vl-convert-python/tests/test_asyncio.py @@ -30,7 +30,11 @@ def public_callable_names(module): @pytest.fixture(autouse=True) def reset_worker_count(): original = vlc.get_converter_config() - vlc.configure_converter(num_workers=1) + vlc.configure_converter( + num_workers=1, + auto_install_fonts=False, + missing_fonts="fallback", + ) try: yield finally: @@ -109,11 +113,15 @@ async def scenario(): num_workers=2, allow_http_access=False, filesystem_root=str(root), + auto_install_fonts=True, + missing_fonts="error", ) config = await vlca.get_converter_config() assert config["num_workers"] == 2 assert config["allow_http_access"] is False assert config["filesystem_root"] == str(root.resolve()) + assert config["auto_install_fonts"] is True + assert config["missing_fonts"] == "error" run(scenario()) diff --git a/vl-convert-python/tests/test_workers.py b/vl-convert-python/tests/test_workers.py index 742e776b..0a9738f9 100644 --- a/vl-convert-python/tests/test_workers.py +++ b/vl-convert-python/tests/test_workers.py @@ -17,7 +17,11 @@ @pytest.fixture(autouse=True) def reset_worker_count(): original = vlc.get_converter_config() - vlc.configure_converter(num_workers=1) + vlc.configure_converter( + num_workers=1, + auto_install_fonts=False, + missing_fonts="fallback", + ) try: yield finally: @@ -92,6 +96,8 @@ def test_configure_converter_round_trip(tmp_path): allow_http_access=False, filesystem_root=str(root), allowed_base_urls=None, + auto_install_fonts=True, + missing_fonts="error", ) config = vlc.get_converter_config() @@ -99,6 +105,8 @@ def test_configure_converter_round_trip(tmp_path): assert config["allow_http_access"] is False assert config["filesystem_root"] == str(root.resolve()) assert config["allowed_base_urls"] is None + assert config["auto_install_fonts"] is True + assert config["missing_fonts"] == "error" def test_configure_converter_num_workers_preserves_access_policy(tmp_path): @@ -125,6 +133,8 @@ def test_configure_converter_noop_when_called_without_args(): allow_http_access=True, filesystem_root=None, allowed_base_urls=["https://example.com/"], + auto_install_fonts=True, + missing_fonts="fallback", ) before = vlc.get_converter_config() vlc.configure_converter() diff --git a/vl-convert-python/thirdparty_rust.yaml b/vl-convert-python/thirdparty_rust.yaml index b210d407..5082ca19 100644 --- a/vl-convert-python/thirdparty_rust.yaml +++ b/vl-convert-python/thirdparty_rust.yaml @@ -1,4 +1,4 @@ -root_name: vl-convert-rs, vl-convert-canvas2d, vl-convert-canvas2d-deno, vl-convert, vl-convert-python, vl-convert-vendor +root_name: vl-convert-rs, vl-convert-canvas2d, vl-convert-canvas2d-deno, vl-convert-fontsource, vl-convert, vl-convert-python, vl-convert-vendor third_party_libraries: - package_name: adler2 package_version: 2.0.1 @@ -18317,6 +18317,34 @@ third_party_libraries: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +- package_name: dashmap + package_version: 6.1.0 + repository: https://github.com/xacrimon/dashmap + license: MIT + licenses: + - license: MIT + text: | + MIT License + + Copyright (c) 2019 Acrimon + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -25590,6 +25618,40 @@ third_party_libraries: DEALINGS IN THE SOFTWARE. - license: Apache-2.0 text: " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" +- package_name: fs4 + package_version: 0.13.1 + repository: https://github.com/al8n/fs4-rs + license: MIT OR Apache-2.0 + licenses: + - license: MIT + text: | + Copyright (c) 2015 The Rust Project Developers + + Permission is hereby granted, free of charge, to any + person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the + Software without restriction, including without + limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software + is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice + shall be included in all copies or substantial portions + of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + - license: Apache-2.0 + text: " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" - package_name: fsevent-sys package_version: 4.1.0 repository: https://github.com/octplane/fsevent-rust/tree/master/fsevent-sys diff --git a/vl-convert-python/vl_convert.pyi b/vl-convert-python/vl_convert.pyi index fcf0600e..5310f840 100644 --- a/vl-convert-python/vl_convert.pyi +++ b/vl-convert-python/vl_convert.pyi @@ -133,6 +133,8 @@ if TYPE_CHECKING: allow_http_access: bool filesystem_root: str | None allowed_base_urls: list[str] | None + auto_install_fonts: bool + missing_fonts: Literal["fallback", "warn", "error"] __all__ = [ "asyncio", @@ -144,6 +146,7 @@ __all__ = [ "get_time_format_locale", "javascript_bundle", "register_font_directory", + "install_font", "warm_up_workers", "svg_to_jpeg", "svg_to_pdf", @@ -267,11 +270,33 @@ def register_font_directory(font_dir: str) -> None: """ ... +def install_font(font_family: str) -> None: + """ + Download, cache, and register a font by family name. + + Downloads font files from the Fontsource catalog (which includes + Google Fonts and other open-source fonts) and registers them for + use in subsequent conversions. + + Parameters + ---------- + font_family + Font family name (e.g. "Roboto", "Playfair Display") + + Returns + ------- + None + """ + ... + def configure_converter( num_workers: int | None = None, allow_http_access: bool | None = None, filesystem_root: str | None = None, allowed_base_urls: list[str] | None = None, + font_cache_size_mb: int | None = None, + auto_install_fonts: bool | None = None, + missing_fonts: Literal["fallback", "warn", "error"] | None = None, ) -> None: """ Configure converter worker/access settings used by subsequent conversions. @@ -292,6 +317,13 @@ def configure_converter( When configured, HTTP redirects are denied instead of followed. Per-call ``allowed_base_urls`` arguments on conversion functions override this converter-level default when provided. + font_cache_size_mb + Maximum font cache size in megabytes. If ``None``, keep current value. + auto_install_fonts + Whether missing fonts may be downloaded from Fontsource. If ``None``, keep current value. + missing_fonts + Missing-font policy: ``"fallback"``, ``"warn"``, or ``"error"``. + If ``None``, keep current value. """ ... @@ -941,12 +973,18 @@ if TYPE_CHECKING: async def register_font_directory(self, font_dir: str) -> None: """Async version of ``register_font_directory``. See sync function for full documentation.""" ... + async def install_font(self, font_family: str) -> None: + """Async version of ``install_font``. See sync function for full documentation.""" + ... async def configure_converter( self, num_workers: int | None = None, allow_http_access: bool | None = None, filesystem_root: str | None = None, allowed_base_urls: list[str] | None = None, + font_cache_size_mb: int | None = None, + auto_install_fonts: bool | None = None, + missing_fonts: Literal["fallback", "warn", "error"] | None = None, ) -> None: """Async version of ``configure_converter``. See sync function for full documentation.""" ... diff --git a/vl-convert-rs/Cargo.toml b/vl-convert-rs/Cargo.toml index d3b18f49..2e14634a 100644 --- a/vl-convert-rs/Cargo.toml +++ b/vl-convert-rs/Cargo.toml @@ -12,6 +12,7 @@ keywords = ["Visualization", "Vega", "Vega-Lite"] [dependencies] vl-convert-canvas2d = { path = "../vl-convert-canvas2d", version = "2.0.0-rc1" } vl-convert-canvas2d-deno = { path = "../vl-convert-canvas2d-deno", version = "2.0.0-rc1", features = ["svg"] } +vl-convert-fontsource = { path = "../vl-convert-fontsource", version = "2.0.0-rc1" } deno_runtime = { workspace = true } deno_core = { workspace = true } diff --git a/vl-convert-rs/src/converter.rs b/vl-convert-rs/src/converter.rs index 45655a06..bbe6aaa4 100644 --- a/vl-convert-rs/src/converter.rs +++ b/vl-convert-rs/src/converter.rs @@ -42,8 +42,14 @@ use image::codecs::jpeg::JpegEncoder; use image::ImageReader; use resvg::render; -use crate::text::{FONT_CONFIG, FONT_CONFIG_VERSION, USVG_OPTIONS}; +use crate::extract::{ + extract_fonts_from_vega, is_available, resolve_first_fonts, FirstFontStatus, FontForHtml, +}; +use crate::text::{ + fetch_and_register_font, FONTSOURCE_CACHE, FONT_CONFIG, FONT_CONFIG_VERSION, USVG_OPTIONS, +}; use std::sync::atomic::{AtomicUsize, Ordering}; +use vl_convert_fontsource::types::family_to_id; // Extension with our custom ops - MainWorker provides all Web APIs (URL, fetch, etc.) // Canvas 2D ops are now in the separate vl_convert_canvas2d extension from vl-convert-canvas2d-deno @@ -441,6 +447,14 @@ impl ValueOrString { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MissingFontsPolicy { + #[default] + Fallback, + Warn, + Error, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct VlConverterConfig { pub num_workers: usize, @@ -450,6 +464,10 @@ pub struct VlConverterConfig { /// values override this default when provided. Must be non-empty when set. /// When configured, HTTP redirects are denied instead of followed. pub allowed_base_urls: Option>, + /// Whether to auto-download missing fonts from the Fontsource catalog. + pub auto_install_fonts: bool, + /// How to handle missing fonts: silently fallback, warn, or error. + pub missing_fonts: MissingFontsPolicy, } impl Default for VlConverterConfig { @@ -459,6 +477,8 @@ impl Default for VlConverterConfig { allow_http_access: true, filesystem_root: None, allowed_base_urls: None, + auto_install_fonts: false, + missing_fonts: MissingFontsPolicy::Fallback, } } } @@ -2297,6 +2317,183 @@ impl VlConvertCommand { /// /// println!("{}", vega_spec) /// ``` +/// +/// Validate font availability and optionally auto-install missing fonts from +/// the Fontsource catalog. +/// +/// This function extracts font-family strings from the spec, classifies the +/// first font in each string, and optionally downloads missing fonts. +async fn preprocess_fonts( + vega_spec: &serde_json::Value, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, +) -> Result, AnyError> { + if !auto_install_fonts && missing_fonts == MissingFontsPolicy::Fallback { + return Ok(Vec::new()); + } + + let font_strings = extract_fonts_from_vega(vega_spec); + if font_strings.is_empty() { + return Ok(Vec::new()); + } + + // Get currently available font families from fontdb + let available: HashSet = USVG_OPTIONS + .lock() + .map_err(|e| anyhow!("font_preprocessing: failed to lock USVG_OPTIONS: {e}"))? + .fontdb + .faces() + .flat_map(|face| face.families.iter().map(|(name, _)| name.clone())) + .collect(); + + let font_string_vec: Vec = font_strings.into_iter().collect(); + + let mut downloadable_set: HashSet = HashSet::new(); + if auto_install_fonts { + // Check which first-fonts are known to Fontsource (only those not already available) + let mut api_errors: Vec<(String, String)> = Vec::new(); + for font_string in &font_string_vec { + let entries = crate::extract::parse_css_font_family(font_string); + if let Some(crate::extract::FontFamilyEntry::Named(ref name)) = entries.first() { + if !is_available(name, &available) { + if let Some(font_id) = family_to_id(name) { + match FONTSOURCE_CACHE.is_known_font(&font_id).await { + Ok(true) => { + downloadable_set.insert(name.clone()); + } + Ok(false) => {} + Err(e) => { + api_errors.push((name.clone(), e.to_string())); + } + } + } + } + } + } + + // Report API errors (network issues) distinctly from "not in catalog" + if !api_errors.is_empty() { + if missing_fonts == MissingFontsPolicy::Error { + let details: Vec = api_errors + .iter() + .map(|(name, err)| format!("'{name}': {err}")) + .collect(); + return Err(anyhow!( + "auto_install_fonts: could not reach the Fontsource API to check \ + the following fonts: {}", + details.join(", ") + )); + } else if missing_fonts == MissingFontsPolicy::Warn { + for (name, err) in &api_errors { + log::warn!( + "auto_install_fonts: could not reach Fontsource API for '{name}': {err}" + ); + } + } + } + } + + // Classify each font string by its first entry + let statuses = resolve_first_fonts(&font_string_vec, &available, |family| { + auto_install_fonts && downloadable_set.contains(family) + }); + + // Collect unavailable fonts — report before any downloads + let unavailable: Vec<(&str, &str)> = statuses + .iter() + .filter_map(|(css_string, status)| match status { + FirstFontStatus::Unavailable { name } => Some((name.as_str(), css_string.as_str())), + _ => None, + }) + .collect(); + + if !unavailable.is_empty() { + let details: Vec = unavailable + .iter() + .map(|(name, css)| { + if *name == *css { + format!("'{name}'") + } else { + format!("'{name}' (from \"{css}\")") + } + }) + .collect(); + if missing_fonts == MissingFontsPolicy::Error { + if auto_install_fonts { + return Err(anyhow!( + "auto_install_fonts: the following fonts are not available on the system \ + and not found in the Fontsource catalog: {}", + details.join(", ") + )); + } else { + return Err(anyhow!( + "missing_fonts=error: the following fonts are not available on the system: {}. \ + Install them with install_font() or enable auto_install_fonts.", + details.join(", ") + )); + } + } + if missing_fonts == MissingFontsPolicy::Warn { + for (name, _css) in &unavailable { + if auto_install_fonts { + log::warn!( + "auto_install_fonts: font '{name}' is not available on the system \ + and not found in the Fontsource catalog, skipping" + ); + } else { + log::warn!("missing_fonts=warn: font '{name}' is not available on the system"); + } + } + } + } + + if !auto_install_fonts { + return Ok(Vec::new()); + } + + // Download and register fonts that need it + let mut downloaded_font_ids: HashSet = HashSet::new(); + let mut html_fonts: Vec = Vec::new(); + for (_css_string, status) in &statuses { + if let FirstFontStatus::NeedsDownload { name } = status { + match fetch_and_register_font(name).await { + Ok(outcome) => { + if outcome.downloaded { + downloaded_font_ids.insert(outcome.font_id.clone()); + } + html_fonts.push(FontForHtml { + family: name.clone(), + font_id: outcome.font_id, + font_type: outcome.font_type.unwrap_or_else(|| "google".to_string()), + }); + } + Err(e) => { + if missing_fonts == MissingFontsPolicy::Error { + return Err(anyhow!( + "auto_install_fonts: failed to download '{name}': {e}" + )); + } else if missing_fonts == MissingFontsPolicy::Warn { + log::warn!("auto_install_fonts: failed to install '{name}': {e}"); + } + } + } + } + } + + // Evict LRU fonts if cache limit is set + let max_bytes = FONTSOURCE_CACHE.max_cache_bytes(); + if max_bytes > 0 && !downloaded_font_ids.is_empty() { + if let Err(e) = FONTSOURCE_CACHE.evict_lru_until_size(max_bytes, &downloaded_font_ids) { + log::warn!("auto_install_fonts: cache eviction failed: {e}"); + } + } + + // Sort for deterministic output + html_fonts.sort_by(|a, b| a.family.cmp(&b.family)); + + Ok(html_fonts) +} + struct VlConverterInner { vegaembed_bundles: Mutex>, pool: Mutex>, @@ -2453,6 +2650,64 @@ impl VlConverter { .await } + fn should_preprocess_fonts(&self) -> bool { + self.inner.config.auto_install_fonts + || self.inner.config.missing_fonts != MissingFontsPolicy::Fallback + } + + /// If font preprocessing is enabled, compile VL→Vega and process referenced fonts. + /// + /// Returns `Some((vega_spec, vg_opts))` with the compiled Vega spec and options + /// for the caller to render directly, or `None` when both font options are disabled. + async fn maybe_compile_vl_with_preprocessed_fonts( + &self, + vl_spec: &ValueOrString, + vl_opts: &VlOpts, + ) -> Result, AnyError> { + if !self.should_preprocess_fonts() { + return Ok(None); + } + let vg_opts = VgOpts { + allowed_base_urls: vl_opts.allowed_base_urls.clone(), + format_locale: vl_opts.format_locale.clone(), + time_format_locale: vl_opts.time_format_locale.clone(), + }; + let vega_spec = self + .vegalite_to_vega(vl_spec.clone(), vl_opts.clone()) + .await?; + // Return value (Vec) will be consumed in PR #247 (HTML font embedding) + let _ = preprocess_fonts( + &vega_spec, + self.inner.config.auto_install_fonts, + self.inner.config.missing_fonts, + ) + .await?; + Ok(Some((vega_spec, vg_opts))) + } + + /// If font preprocessing is enabled, parse the Vega spec and process missing fonts. + /// + /// Note: font downloads are governed solely by `auto_install_fonts`, independently + /// of `allow_http_access`. The two settings control different concerns: + /// `allow_http_access` governs data-fetching URLs in specs, while `auto_install_fonts` + /// governs on-demand font installation from Fontsource. + async fn maybe_preprocess_vega_fonts(&self, spec: &ValueOrString) -> Result<(), AnyError> { + if self.should_preprocess_fonts() { + let spec_value: serde_json::Value = match spec { + ValueOrString::JsonString(s) => serde_json::from_str(s)?, + ValueOrString::Value(v) => v.clone(), + }; + // Return value (Vec) will be consumed in PR #247 (HTML font embedding) + let _ = preprocess_fonts( + &spec_value, + self.inner.config.auto_install_fonts, + self.inner.config.missing_fonts, + ) + .await?; + } + Ok(()) + } + pub async fn vega_to_svg( &self, vg_spec: impl Into, @@ -2461,6 +2716,8 @@ impl VlConverter { vg_opts.allowed_base_urls = self.effective_allowed_base_urls(vg_opts.allowed_base_urls.take())?; let vg_spec = vg_spec.into(); + self.maybe_preprocess_vega_fonts(&vg_spec).await?; + self.request( move |responder| VlConvertCommand::VgToSvg { vg_spec, @@ -2480,6 +2737,7 @@ impl VlConverter { vg_opts.allowed_base_urls = self.effective_allowed_base_urls(vg_opts.allowed_base_urls.take())?; let vg_spec = vg_spec.into(); + self.maybe_preprocess_vega_fonts(&vg_spec).await?; self.request( move |responder| VlConvertCommand::VgToSg { vg_spec, @@ -2499,6 +2757,7 @@ impl VlConverter { vg_opts.allowed_base_urls = self.effective_allowed_base_urls(vg_opts.allowed_base_urls.take())?; let vg_spec = vg_spec.into(); + self.maybe_preprocess_vega_fonts(&vg_spec).await?; self.request( move |responder| VlConvertCommand::VgToSgMsgpack { vg_spec, @@ -2518,15 +2777,32 @@ impl VlConverter { vl_opts.allowed_base_urls = self.effective_allowed_base_urls(vl_opts.allowed_base_urls.take())?; let vl_spec = vl_spec.into(); - self.request( - move |responder| VlConvertCommand::VlToSvg { - vl_spec, - vl_opts, - responder, - }, - "Vega-Lite to SVG conversion", - ) - .await + + if let Some((vega_spec, vg_opts)) = self + .maybe_compile_vl_with_preprocessed_fonts(&vl_spec, &vl_opts) + .await? + { + let vg_spec: ValueOrString = vega_spec.into(); + self.request( + move |responder| VlConvertCommand::VgToSvg { + vg_spec, + vg_opts, + responder, + }, + "Vega to SVG conversion", + ) + .await + } else { + self.request( + move |responder| VlConvertCommand::VlToSvg { + vl_spec, + vl_opts, + responder, + }, + "Vega-Lite to SVG conversion", + ) + .await + } } pub async fn vegalite_to_scenegraph( @@ -2537,15 +2813,32 @@ impl VlConverter { vl_opts.allowed_base_urls = self.effective_allowed_base_urls(vl_opts.allowed_base_urls.take())?; let vl_spec = vl_spec.into(); - self.request( - move |responder| VlConvertCommand::VlToSg { - vl_spec, - vl_opts, - responder, - }, - "Vega-Lite to Scenegraph conversion", - ) - .await + + if let Some((vega_spec, vg_opts)) = self + .maybe_compile_vl_with_preprocessed_fonts(&vl_spec, &vl_opts) + .await? + { + let vg_spec: ValueOrString = vega_spec.into(); + self.request( + move |responder| VlConvertCommand::VgToSg { + vg_spec, + vg_opts, + responder, + }, + "Vega to Scenegraph conversion", + ) + .await + } else { + self.request( + move |responder| VlConvertCommand::VlToSg { + vl_spec, + vl_opts, + responder, + }, + "Vega-Lite to Scenegraph conversion", + ) + .await + } } pub async fn vegalite_to_scenegraph_msgpack( @@ -2556,15 +2849,32 @@ impl VlConverter { vl_opts.allowed_base_urls = self.effective_allowed_base_urls(vl_opts.allowed_base_urls.take())?; let vl_spec = vl_spec.into(); - self.request( - move |responder| VlConvertCommand::VlToSgMsgpack { - vl_spec, - vl_opts, - responder, - }, - "Vega-Lite to Scenegraph conversion", - ) - .await + + if let Some((vega_spec, vg_opts)) = self + .maybe_compile_vl_with_preprocessed_fonts(&vl_spec, &vl_opts) + .await? + { + let vg_spec: ValueOrString = vega_spec.into(); + self.request( + move |responder| VlConvertCommand::VgToSgMsgpack { + vg_spec, + vg_opts, + responder, + }, + "Vega to Scenegraph conversion", + ) + .await + } else { + self.request( + move |responder| VlConvertCommand::VlToSgMsgpack { + vl_spec, + vl_opts, + responder, + }, + "Vega-Lite to Scenegraph conversion", + ) + .await + } } pub async fn vega_to_png( @@ -2581,6 +2891,8 @@ impl VlConverter { let effective_scale = scale * ppi / 72.0; let vg_spec = vg_spec.into(); + self.maybe_preprocess_vega_fonts(&vg_spec).await?; + self.request( move |responder| VlConvertCommand::VgToPng { vg_spec, @@ -2603,22 +2915,40 @@ impl VlConverter { ) -> Result, AnyError> { vl_opts.allowed_base_urls = self.effective_allowed_base_urls(vl_opts.allowed_base_urls.take())?; + let vl_spec = vl_spec.into(); let scale = scale.unwrap_or(1.0); let ppi = ppi.unwrap_or(72.0); let effective_scale = scale * ppi / 72.0; - let vl_spec = vl_spec.into(); - self.request( - move |responder| VlConvertCommand::VlToPng { - vl_spec, - vl_opts, - scale: effective_scale, - ppi, - responder, - }, - "Vega-Lite to PNG conversion", - ) - .await + if let Some((vega_spec, vg_opts)) = self + .maybe_compile_vl_with_preprocessed_fonts(&vl_spec, &vl_opts) + .await? + { + let vg_spec: ValueOrString = vega_spec.into(); + self.request( + move |responder| VlConvertCommand::VgToPng { + vg_spec, + vg_opts, + scale: effective_scale, + ppi, + responder, + }, + "Vega to PNG conversion", + ) + .await + } else { + self.request( + move |responder| VlConvertCommand::VlToPng { + vl_spec, + vl_opts, + scale: effective_scale, + ppi, + responder, + }, + "Vega-Lite to PNG conversion", + ) + .await + } } pub async fn vega_to_jpeg( diff --git a/vl-convert-rs/src/extract.rs b/vl-convert-rs/src/extract.rs new file mode 100644 index 00000000..09e796b4 --- /dev/null +++ b/vl-convert-rs/src/extract.rs @@ -0,0 +1,1362 @@ +use serde_json::Value; +use std::collections::HashSet; + +/// Metadata for a font that should be loaded via CDN in HTML output. +#[derive(Debug, Clone)] +pub struct FontForHtml { + /// The font family name (e.g., "Roboto", "Playfair Display"). + pub family: String, + /// The Fontsource font ID (e.g., "roboto", "playfair-display"). + pub font_id: String, + /// Whether this is a Google font ("google") or other ("other"). + pub font_type: String, +} + +// --------------------------------------------------------------------------- +// CSS generic family keywords (case-sensitive, per CSS spec) +// --------------------------------------------------------------------------- + +const GENERIC_FAMILIES: &[&str] = &[ + "serif", + "sans-serif", + "monospace", + "cursive", + "fantasy", + "system-ui", + "ui-serif", + "ui-sans-serif", + "ui-monospace", + "ui-rounded", + "emoji", + "math", + "fangsong", +]; + +/// A single entry from a parsed CSS `font-family` string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FontFamilyEntry { + /// A concrete font family name (e.g. "Roboto", "Playfair Display"). + Named(String), + /// A CSS generic family keyword (e.g. "serif", "sans-serif"). + Generic(String), +} + +// --------------------------------------------------------------------------- +// 1. CSS font-family parser +// --------------------------------------------------------------------------- + +/// Parse a CSS `font-family` string into a list of [`FontFamilyEntry`] values. +/// +/// The input is a comma-separated list of family names, each optionally +/// enclosed in single or double quotes. Generic family keywords are +/// recognised case-sensitively. +/// +/// # Examples +/// +/// ``` +/// use vl_convert_rs::extract::{parse_css_font_family, FontFamilyEntry}; +/// +/// let entries = parse_css_font_family("Roboto, sans-serif"); +/// assert_eq!(entries, vec![ +/// FontFamilyEntry::Named("Roboto".into()), +/// FontFamilyEntry::Generic("sans-serif".into()), +/// ]); +/// ``` +pub fn parse_css_font_family(s: &str) -> Vec { + s.split(',') + .filter_map(|segment| { + let trimmed = segment.trim(); + if trimmed.is_empty() { + return None; + } + + // Strip matching outer quotes (single or double). + let unquoted = strip_quotes(trimmed); + + if unquoted.is_empty() { + return None; + } + + let lower = unquoted.to_lowercase(); + if GENERIC_FAMILIES.iter().any(|g| g.to_lowercase() == lower) { + Some(FontFamilyEntry::Generic(unquoted.to_string())) + } else { + Some(FontFamilyEntry::Named(unquoted.to_string())) + } + }) + .collect() +} + +/// Strip matching outer single or double quotes from a string. +fn strip_quotes(s: &str) -> &str { + if s.len() >= 2 { + let bytes = s.as_bytes(); + if (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'') + || (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"') + { + return &s[1..s.len() - 1]; + } + } + s +} + +// --------------------------------------------------------------------------- +// 2. Font extraction from compiled Vega specs +// --------------------------------------------------------------------------- + +/// Extract all font-family CSS strings from a compiled Vega specification. +/// +/// Returns a deduplicated set of raw CSS font-family strings found in static +/// positions throughout the spec (config, marks, axes, legends, title, +/// data transforms). Dynamic references (signal/field) are skipped. +pub fn extract_fonts_from_vega(spec: &Value) -> HashSet { + let mut fonts = HashSet::new(); + + // Config + if let Some(config) = spec.get("config") { + extract_config_fonts(config, &mut fonts); + } + + // Top-level marks (recursive) + if let Some(marks) = spec.get("marks") { + extract_marks_fonts(marks, &mut fonts); + } + + // Top-level axes + if let Some(axes) = spec.get("axes") { + extract_axes_fonts(axes, &mut fonts); + } + + // Top-level legends + if let Some(legends) = spec.get("legends") { + extract_legends_fonts(legends, &mut fonts); + } + + // Top-level title + if let Some(title) = spec.get("title") { + extract_title_fonts(title, &mut fonts); + } + + // Data transforms (e.g. wordcloud) + if let Some(data) = spec.get("data").and_then(Value::as_array) { + for dataset in data { + if let Some(transforms) = dataset.get("transform").and_then(Value::as_array) { + for transform in transforms { + extract_transform_fonts(transform, &mut fonts); + } + } + } + } + + fonts +} + +// ---- Config extraction ---------------------------------------------------- + +/// Axis config key variants (per Vega's AxisConfigKeys type). +const AXIS_CONFIG_KEYS: &[&str] = &[ + "axis", + "axisX", + "axisY", + "axisTop", + "axisBottom", + "axisLeft", + "axisRight", + "axisBand", + "axisDiscrete", + "axisPoint", + "axisQuantitative", + "axisTemporal", +]; + +/// Vega mark types whose config can carry a `font` property. +/// Only `text` renders text; other native marks (arc, area, etc.) ignore `font`. +const MARK_TYPE_KEYS: &[&str] = &["text"]; + +fn extract_config_fonts(config: &Value, fonts: &mut HashSet) { + // Title + if let Some(title) = config.get("title") { + collect_if_string(title, "font", fonts); + collect_if_string(title, "subtitleFont", fonts); + } + + // Axis variants + for &key in AXIS_CONFIG_KEYS { + if let Some(axis) = config.get(key) { + collect_if_string(axis, "labelFont", fonts); + collect_if_string(axis, "titleFont", fonts); + } + } + + // Legend + if let Some(legend) = config.get("legend") { + collect_if_string(legend, "labelFont", fonts); + collect_if_string(legend, "titleFont", fonts); + } + + // Mark type defaults + for &key in MARK_TYPE_KEYS { + if let Some(mark_cfg) = config.get(key) { + collect_if_string(mark_cfg, "font", fonts); + } + } + + // Top-level default font + collect_if_string(config, "font", fonts); + + // Mark default: config.mark.font + if let Some(mark) = config.get("mark") { + collect_if_string(mark, "font", fonts); + } + + // Header variants + for &key in &["header", "headerColumn", "headerRow", "headerFacet"] { + if let Some(header) = config.get(key) { + collect_if_string(header, "labelFont", fonts); + collect_if_string(header, "titleFont", fonts); + } + } + + // Named styles: config.style is an object { styleName: { font, ... } } + if let Some(style) = config.get("style").and_then(Value::as_object) { + for (_style_name, style_obj) in style { + collect_if_string(style_obj, "font", fonts); + collect_if_string(style_obj, "labelFont", fonts); + collect_if_string(style_obj, "titleFont", fonts); + } + } +} + +// ---- Mark extraction (recursive) ------------------------------------------ + +fn extract_marks_fonts(marks: &Value, fonts: &mut HashSet) { + let arr = match marks.as_array() { + Some(a) => a, + None => return, + }; + + for mark in arr { + // Encode blocks: enter, update, hover, exit + if let Some(encode) = mark.get("encode") { + for &state in &[ + "enter", "update", "hover", "exit", "leave", "select", "release", + ] { + if let Some(font_val) = encode + .get(state) + .and_then(|s| s.get("font")) + .and_then(|f| f.get("value")) + .and_then(Value::as_str) + { + fonts.insert(font_val.to_string()); + } + } + } + + // Group marks: recurse into nested marks, axes, legends + if let Some(nested_marks) = mark.get("marks") { + extract_marks_fonts(nested_marks, fonts); + } + if let Some(nested_axes) = mark.get("axes") { + extract_axes_fonts(nested_axes, fonts); + } + if let Some(nested_legends) = mark.get("legends") { + extract_legends_fonts(nested_legends, fonts); + } + // Also check for nested title within group marks + if let Some(nested_title) = mark.get("title") { + extract_title_fonts(nested_title, fonts); + } + } +} + +// ---- Axis extraction ------------------------------------------------------ + +fn extract_axes_fonts(axes: &Value, fonts: &mut HashSet) { + let arr = match axes.as_array() { + Some(a) => a, + None => return, + }; + + for axis in arr { + // Direct properties + collect_if_string(axis, "labelFont", fonts); + collect_if_string(axis, "titleFont", fonts); + + // Encode paths: encode.{labels,title}.{state}.font.value + if let Some(encode) = axis.get("encode") { + for &part in &["labels", "title"] { + if let Some(part_obj) = encode.get(part) { + for &state in &[ + "enter", "update", "hover", "exit", "leave", "select", "release", + ] { + if let Some(font_val) = part_obj + .get(state) + .and_then(|s| s.get("font")) + .and_then(|f| f.get("value")) + .and_then(Value::as_str) + { + fonts.insert(font_val.to_string()); + } + } + } + } + } + } +} + +// ---- Legend extraction ----------------------------------------------------- + +fn extract_legends_fonts(legends: &Value, fonts: &mut HashSet) { + let arr = match legends.as_array() { + Some(a) => a, + None => return, + }; + + for legend in arr { + // Direct properties + collect_if_string(legend, "labelFont", fonts); + collect_if_string(legend, "titleFont", fonts); + + // Encode paths: encode.{labels,title}.{state}.font.value + if let Some(encode) = legend.get("encode") { + for &part in &["labels", "title"] { + if let Some(part_obj) = encode.get(part) { + for &state in &[ + "enter", "update", "hover", "exit", "leave", "select", "release", + ] { + if let Some(font_val) = part_obj + .get(state) + .and_then(|s| s.get("font")) + .and_then(|f| f.get("value")) + .and_then(Value::as_str) + { + fonts.insert(font_val.to_string()); + } + } + } + } + } + } +} + +// ---- Title extraction ----------------------------------------------------- + +fn extract_title_fonts(title: &Value, fonts: &mut HashSet) { + // Title can be a string (no font info) or an object. + if title.is_string() { + return; + } + collect_if_string(title, "font", fonts); + collect_if_string(title, "subtitleFont", fonts); + + // Encode paths: encode.{title,subtitle}.{state}.font.value + if let Some(encode) = title.get("encode") { + for &part in &["title", "subtitle"] { + if let Some(part_obj) = encode.get(part) { + for &state in &[ + "enter", "update", "hover", "exit", "leave", "select", "release", + ] { + if let Some(font_val) = part_obj + .get(state) + .and_then(|s| s.get("font")) + .and_then(|f| f.get("value")) + .and_then(Value::as_str) + { + fonts.insert(font_val.to_string()); + } + } + } + } + } +} + +// ---- Transform extraction ------------------------------------------------- + +fn extract_transform_fonts(transform: &Value, fonts: &mut HashSet) { + // Wordcloud transforms: { "type": "wordcloud", "font": "..." } + let is_wordcloud = transform + .get("type") + .and_then(Value::as_str) + .map(|t| t == "wordcloud") + .unwrap_or(false); + + if is_wordcloud { + collect_if_string(transform, "font", fonts); + } +} + +// ---- Shared helper -------------------------------------------------------- + +/// If `obj[key]` is a JSON string, insert it into `fonts`. +fn collect_if_string(obj: &Value, key: &str, fonts: &mut HashSet) { + if let Some(val) = obj.get(key).and_then(Value::as_str) { + if !val.is_empty() { + fonts.insert(val.to_string()); + } + } +} + +// --------------------------------------------------------------------------- +// 3. Resolution: determine which fonts to download +// --------------------------------------------------------------------------- + +/// Classification of the first font in a CSS `font-family` string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FirstFontStatus { + /// First entry is a CSS generic keyword (serif, sans-serif, etc.) — + /// always satisfied by the system font configuration. + Generic, + /// First entry is already registered in fontdb. + Available { name: String }, + /// First entry is downloadable from Fontsource. + NeedsDownload { name: String }, + /// First entry is not on the system and not on Fontsource. + Unavailable { name: String }, +} + +/// Classify each font-family string by examining only the **first** entry. +/// +/// For each CSS font-family string, the first entry is checked: +/// +/// 1. **Generic** keyword (serif, sans-serif, etc.) → [`FirstFontStatus::Generic`] +/// 2. **Named** family already in `available` → [`FirstFontStatus::Available`] +/// 3. **Named** family for which `downloadable(family)` returns `true` → +/// [`FirstFontStatus::NeedsDownload`] +/// 4. **Named** family that is neither available nor downloadable → +/// [`FirstFontStatus::Unavailable`] +/// +/// Only the first entry matters — the rest of the fallback chain is ignored. +/// Results are deduplicated by CSS string. +pub fn resolve_first_fonts( + font_strings: &[String], + available: &HashSet, + downloadable: impl Fn(&str) -> bool, +) -> Vec<(String, FirstFontStatus)> { + let mut results: Vec<(String, FirstFontStatus)> = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + for font_string in font_strings { + if !seen.insert(font_string.clone()) { + continue; + } + + let entries = parse_css_font_family(font_string); + let status = match entries.first() { + None => continue, // empty/whitespace-only string + Some(FontFamilyEntry::Generic(_)) => FirstFontStatus::Generic, + Some(FontFamilyEntry::Named(name)) => { + if is_available(name, available) { + FirstFontStatus::Available { name: name.clone() } + } else if downloadable(name) { + FirstFontStatus::NeedsDownload { name: name.clone() } + } else { + FirstFontStatus::Unavailable { name: name.clone() } + } + } + }; + + results.push((font_string.clone(), status)); + } + + results +} + +/// Case-insensitive membership check against the available font set. +/// +/// The `available` set is expected to contain font names in their original +/// casing (as reported by fontdb). We check both the exact name and a +/// lowercased version. +pub fn is_available(name: &str, available: &HashSet) -> bool { + if available.contains(name) { + return true; + } + let lower = name.to_lowercase(); + available.iter().any(|a| a.to_lowercase() == lower) +} + +// =========================================================================== +// Tests +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // ----------------------------------------------------------------------- + // 1. CSS parser tests + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_single_named() { + assert_eq!( + parse_css_font_family("Roboto"), + vec![FontFamilyEntry::Named("Roboto".into())] + ); + } + + #[test] + fn test_parse_named_and_generic() { + assert_eq!( + parse_css_font_family("Roboto, sans-serif"), + vec![ + FontFamilyEntry::Named("Roboto".into()), + FontFamilyEntry::Generic("sans-serif".into()), + ] + ); + } + + #[test] + fn test_parse_single_quoted() { + assert_eq!( + parse_css_font_family("'Playfair Display', Georgia, serif"), + vec![ + FontFamilyEntry::Named("Playfair Display".into()), + FontFamilyEntry::Named("Georgia".into()), + FontFamilyEntry::Generic("serif".into()), + ] + ); + } + + #[test] + fn test_parse_double_quoted() { + assert_eq!( + parse_css_font_family("\"IBM Plex Sans\""), + vec![FontFamilyEntry::Named("IBM Plex Sans".into())] + ); + } + + #[test] + fn test_parse_all_generics() { + for &generic in GENERIC_FAMILIES { + let entries = parse_css_font_family(generic); + assert_eq!( + entries, + vec![FontFamilyEntry::Generic(generic.into())], + "failed for generic: {}", + generic + ); + } + } + + #[test] + fn test_parse_empty_string() { + assert!(parse_css_font_family("").is_empty()); + } + + #[test] + fn test_parse_only_commas() { + assert!(parse_css_font_family(",,,").is_empty()); + } + + #[test] + fn test_parse_whitespace_around_commas() { + assert_eq!( + parse_css_font_family(" Roboto , Arial , monospace "), + vec![ + FontFamilyEntry::Named("Roboto".into()), + FontFamilyEntry::Named("Arial".into()), + FontFamilyEntry::Generic("monospace".into()), + ] + ); + } + + #[test] + fn test_parse_mixed_quotes() { + assert_eq!( + parse_css_font_family("'Times New Roman', \"Courier New\", monospace"), + vec![ + FontFamilyEntry::Named("Times New Roman".into()), + FontFamilyEntry::Named("Courier New".into()), + FontFamilyEntry::Generic("monospace".into()), + ] + ); + } + + #[test] + fn test_parse_unquoted_multi_word() { + assert_eq!( + parse_css_font_family("Segoe UI"), + vec![FontFamilyEntry::Named("Segoe UI".into())] + ); + } + + #[test] + fn test_parse_power_bi_chain() { + assert_eq!( + parse_css_font_family("wf_standard-font, helvetica, arial, sans-serif"), + vec![ + FontFamilyEntry::Named("wf_standard-font".into()), + FontFamilyEntry::Named("helvetica".into()), + FontFamilyEntry::Named("arial".into()), + FontFamilyEntry::Generic("sans-serif".into()), + ] + ); + } + + // ----------------------------------------------------------------------- + // 2. Extraction tests + // ----------------------------------------------------------------------- + + #[test] + fn test_extract_config_fonts() { + let spec = json!({ + "config": { + "title": { + "font": "Playfair Display, Georgia, serif", + "subtitleFont": "Source Sans Pro" + }, + "axis": { + "labelFont": "Fira Code, monospace", + "titleFont": "Roboto" + }, + "legend": { + "labelFont": "Noto Sans", + "titleFont": "Noto Serif" + }, + "text": { + "font": "IBM Plex Mono" + }, + "style": { + "guide-label": { + "font": "Lato" + }, + "group-title": { + "font": "Oswald" + } + } + } + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Playfair Display, Georgia, serif")); + assert!(fonts.contains("Source Sans Pro")); + assert!(fonts.contains("Fira Code, monospace")); + assert!(fonts.contains("Roboto")); + assert!(fonts.contains("Noto Sans")); + assert!(fonts.contains("Noto Serif")); + assert!(fonts.contains("IBM Plex Mono")); + assert!(fonts.contains("Lato")); + assert!(fonts.contains("Oswald")); + } + + #[test] + fn test_extract_mark_fonts() { + let spec = json!({ + "marks": [ + { + "type": "text", + "encode": { + "enter": { + "font": { "value": "Merriweather" } + }, + "update": { + "font": { "value": "Roboto Mono" } + } + } + }, + { + "type": "text", + "encode": { + "update": { + "font": { "signal": "dynamicFont" } + } + } + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Merriweather")); + assert!(fonts.contains("Roboto Mono")); + // Signal-driven font should NOT be extracted. + assert_eq!(fonts.len(), 2); + } + + #[test] + fn test_extract_nested_group_marks() { + let spec = json!({ + "marks": [ + { + "type": "group", + "marks": [ + { + "type": "text", + "encode": { + "update": { + "font": { "value": "Cabin" } + } + } + } + ], + "axes": [ + { "labelFont": "Inconsolata" } + ], + "legends": [ + { "titleFont": "Open Sans" } + ] + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Cabin")); + assert!(fonts.contains("Inconsolata")); + assert!(fonts.contains("Open Sans")); + } + + #[test] + fn test_extract_axes_fonts() { + let spec = json!({ + "axes": [ + { + "labelFont": "Fira Sans", + "titleFont": "Fira Sans Bold" + }, + { + "encode": { + "labels": { + "update": { + "font": { "value": "Droid Sans" } + } + }, + "title": { + "update": { + "font": { "value": "Droid Serif" } + } + } + } + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Fira Sans")); + assert!(fonts.contains("Fira Sans Bold")); + assert!(fonts.contains("Droid Sans")); + assert!(fonts.contains("Droid Serif")); + } + + #[test] + fn test_extract_legends_fonts() { + let spec = json!({ + "legends": [ + { + "labelFont": "PT Sans", + "titleFont": "PT Serif" + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("PT Sans")); + assert!(fonts.contains("PT Serif")); + } + + #[test] + fn test_extract_legends_encode_fonts() { + let spec = json!({ + "legends": [ + { + "encode": { + "labels": { + "update": { + "font": { "value": "Droid Sans" } + } + }, + "title": { + "update": { + "font": { "value": "Droid Serif" } + } + } + } + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Droid Sans")); + assert!(fonts.contains("Droid Serif")); + assert_eq!(fonts.len(), 2); + } + + #[test] + fn test_extract_title_fonts() { + let spec = json!({ + "title": { + "text": "My Chart", + "font": "Montserrat, sans-serif", + "subtitleFont": "Lora" + } + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Montserrat, sans-serif")); + assert!(fonts.contains("Lora")); + } + + #[test] + fn test_extract_title_encode_fonts() { + let spec = json!({ + "title": { + "text": "My Chart", + "encode": { + "title": { + "update": { + "font": { "value": "Montserrat" } + } + }, + "subtitle": { + "update": { + "font": { "value": "Lora" } + } + } + } + } + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Montserrat")); + assert!(fonts.contains("Lora")); + assert_eq!(fonts.len(), 2); + } + + #[test] + fn test_extract_title_string_only() { + // When title is just a string, there's no font info. + let spec = json!({ + "title": "My Chart" + }); + + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.is_empty()); + } + + #[test] + fn test_extract_wordcloud_transform() { + let spec = json!({ + "data": [ + { + "name": "table", + "transform": [ + { + "type": "wordcloud", + "font": "Pacifico" + }, + { + "type": "formula", + "as": "weight" + } + ] + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Pacifico")); + assert_eq!(fonts.len(), 1); + } + + #[test] + fn test_extract_wordcloud_signal_font_skipped() { + let spec = json!({ + "data": [ + { + "name": "table", + "transform": [ + { + "type": "wordcloud", + "font": { "signal": "fontChoice" } + } + ] + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + // Signal-based font in wordcloud → not a string, skipped. + assert!(fonts.is_empty()); + } + + #[test] + fn test_extract_axis_orientation_overrides() { + let spec = json!({ + "config": { + "axisX": { "labelFont": "Barlow" }, + "axisY": { "titleFont": "Barlow Condensed" }, + "axisTop": { "labelFont": "Rubik" }, + "axisBottom": { "titleFont": "Ubuntu" }, + "axisLeft": { "labelFont": "Quicksand" }, + "axisRight": { "titleFont": "Karla" }, + "axisBand": { "labelFont": "Manrope" } + } + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Barlow")); + assert!(fonts.contains("Barlow Condensed")); + assert!(fonts.contains("Rubik")); + assert!(fonts.contains("Ubuntu")); + assert!(fonts.contains("Quicksand")); + assert!(fonts.contains("Karla")); + assert!(fonts.contains("Manrope")); + } + + #[test] + fn test_extract_text_mark_config() { + let spec = json!({ + "config": { + "text": { "font": "IBM Plex Mono" } + } + }); + + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.contains("IBM Plex Mono")); + assert_eq!(fonts.len(), 1); + } + + #[test] + fn test_extract_config_style_label_title_fonts() { + let spec = json!({ + "config": { + "style": { + "guide-label": { + "labelFont": "Asap", + "titleFont": "Assistant" + } + } + } + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Asap")); + assert!(fonts.contains("Assistant")); + } + + #[test] + fn test_extract_deduplicates() { + let spec = json!({ + "config": { + "axis": { + "labelFont": "Roboto", + "titleFont": "Roboto" + } + }, + "axes": [ + { "labelFont": "Roboto" } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Roboto")); + assert_eq!(fonts.len(), 1); + } + + #[test] + fn test_extract_comprehensive_fixture() { + let spec = json!({ + "config": { + "title": { "font": "Playfair Display, serif" }, + "axis": { "labelFont": "Fira Code, monospace" }, + "text": { "font": "IBM Plex Sans" }, + "style": { + "guide-label": { "font": "Lato" } + } + }, + "marks": [ + { + "type": "text", + "encode": { + "update": { + "font": { "value": "Merriweather" } + } + } + }, + { + "type": "group", + "marks": [ + { + "type": "text", + "encode": { + "enter": { + "font": { "value": "Cabin" } + } + } + } + ], + "axes": [ + { "labelFont": "Inconsolata" } + ] + } + ], + "axes": [ + { "titleFont": "Source Sans Pro" } + ], + "legends": [ + { "labelFont": "Noto Sans" } + ], + "title": { + "text": "Chart Title", + "font": "Montserrat", + "subtitleFont": "Lora" + }, + "data": [ + { + "name": "words", + "transform": [ + { "type": "wordcloud", "font": "Pacifico" } + ] + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + + let expected: HashSet = [ + "Playfair Display, serif", + "Fira Code, monospace", + "IBM Plex Sans", + "Lato", + "Merriweather", + "Cabin", + "Inconsolata", + "Source Sans Pro", + "Noto Sans", + "Montserrat", + "Lora", + "Pacifico", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + + assert_eq!(fonts, expected); + } + + // ----------------------------------------------------------------------- + // 3. Resolution tests (first-font-only semantics) + // ----------------------------------------------------------------------- + + #[test] + fn test_resolve_first_font_generic() { + // "serif" → first entry is generic → Generic + let font_strings = vec!["serif".to_string()]; + let available: HashSet = HashSet::new(); + let downloadable = |_: &str| true; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].1, FirstFontStatus::Generic); + } + + #[test] + fn test_resolve_first_font_available() { + // "Arial, sans-serif" → first entry is Arial, which is available + let font_strings = vec!["Arial, sans-serif".to_string()]; + let available: HashSet = ["Arial".to_string()].into(); + let downloadable = |_: &str| false; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0].1, + FirstFontStatus::Available { + name: "Arial".into() + } + ); + } + + #[test] + fn test_resolve_first_font_downloadable() { + // "Roboto, sans-serif" → first entry is Roboto, downloadable + let font_strings = vec!["Roboto, sans-serif".to_string()]; + let available: HashSet = HashSet::new(); + let downloadable = |name: &str| name == "Roboto"; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0].1, + FirstFontStatus::NeedsDownload { + name: "Roboto".into() + } + ); + } + + #[test] + fn test_resolve_first_font_unavailable() { + // "Benton Gothic, Roboto, sans-serif" + // First entry is Benton Gothic: not available, not downloadable → Unavailable + // Roboto (second in chain) is NOT considered. + let font_strings = vec!["Benton Gothic, Roboto, sans-serif".to_string()]; + let available: HashSet = HashSet::new(); + let downloadable = |name: &str| name == "Roboto"; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0].1, + FirstFontStatus::Unavailable { + name: "Benton Gothic".into() + } + ); + } + + #[test] + fn test_resolve_first_font_case_insensitive_available() { + // fontdb might report "arial" but the spec has "Arial" + let font_strings = vec!["Arial, sans-serif".to_string()]; + let available: HashSet = ["arial".to_string()].into(); + let downloadable = |_: &str| true; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0].1, + FirstFontStatus::Available { + name: "Arial".into() + } + ); + } + + #[test] + fn test_resolve_deduplicates() { + // Same CSS string appears twice — only one result entry + let font_strings = vec![ + "Roboto, sans-serif".to_string(), + "Roboto, sans-serif".to_string(), + ]; + let available: HashSet = HashSet::new(); + let downloadable = |name: &str| name == "Roboto"; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0].1, + FirstFontStatus::NeedsDownload { + name: "Roboto".into() + } + ); + } + + #[test] + fn test_resolve_multiple_different_fonts() { + let font_strings = vec![ + "Inter".to_string(), + "Playfair Display, Georgia, serif".to_string(), + "Fira Code, Courier New, monospace".to_string(), + ]; + let available: HashSet = HashSet::new(); + let downloadable = |name: &str| matches!(name, "Inter" | "Playfair Display" | "Fira Code"); + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 3); + assert_eq!( + result[0].1, + FirstFontStatus::NeedsDownload { + name: "Inter".into() + } + ); + assert_eq!( + result[1].1, + FirstFontStatus::NeedsDownload { + name: "Playfair Display".into() + } + ); + assert_eq!( + result[2].1, + FirstFontStatus::NeedsDownload { + name: "Fira Code".into() + } + ); + } + + #[test] + fn test_resolve_empty_input() { + let font_strings: Vec = vec![]; + let available: HashSet = HashSet::new(); + let downloadable = |_: &str| true; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert!(result.is_empty()); + } + + // ----------------------------------------------------------------------- + // Case-insensitive generic matching tests + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_generic_case_insensitive() { + // "Sans-Serif" (title-case) should be classified as Generic + let entries = parse_css_font_family("Sans-Serif"); + assert_eq!(entries, vec![FontFamilyEntry::Generic("Sans-Serif".into())]); + } + + #[test] + fn test_parse_generic_uppercase() { + let entries = parse_css_font_family("MONOSPACE"); + assert_eq!(entries, vec![FontFamilyEntry::Generic("MONOSPACE".into())]); + } + + // ----------------------------------------------------------------------- + // Missing config keys tests + // ----------------------------------------------------------------------- + + #[test] + fn test_extract_config_font_top_level() { + let spec = json!({ + "config": { + "font": "Global Font" + } + }); + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.contains("Global Font")); + } + + #[test] + fn test_extract_config_mark_font() { + let spec = json!({ + "config": { + "mark": { "font": "Mark Default Font" } + } + }); + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.contains("Mark Default Font")); + } + + #[test] + fn test_extract_config_header_fonts() { + let spec = json!({ + "config": { + "header": { "labelFont": "Header Label", "titleFont": "Header Title" }, + "headerColumn": { "labelFont": "ColHeader Label" }, + "headerRow": { "titleFont": "RowHeader Title" }, + "headerFacet": { "labelFont": "FacetHeader Label" } + } + }); + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.contains("Header Label")); + assert!(fonts.contains("Header Title")); + assert!(fonts.contains("ColHeader Label")); + assert!(fonts.contains("RowHeader Title")); + assert!(fonts.contains("FacetHeader Label")); + } + + #[test] + fn test_extract_config_axis_discrete_point_quantitative_temporal() { + let spec = json!({ + "config": { + "axisDiscrete": { "labelFont": "Discrete Font" }, + "axisPoint": { "titleFont": "Point Font" }, + "axisQuantitative": { "labelFont": "Quant Font" }, + "axisTemporal": { "titleFont": "Temporal Font" } + } + }); + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.contains("Discrete Font")); + assert!(fonts.contains("Point Font")); + assert!(fonts.contains("Quant Font")); + assert!(fonts.contains("Temporal Font")); + } + + // ----------------------------------------------------------------------- + // Encode traversal: non-update states + // ----------------------------------------------------------------------- + + #[test] + fn test_extract_axis_encode_enter_state() { + let spec = json!({ + "axes": [{ + "encode": { + "labels": { + "enter": { + "font": { "value": "Enter Font" } + } + } + } + }] + }); + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.contains("Enter Font")); + } + + #[test] + fn test_extract_legend_encode_hover_state() { + let spec = json!({ + "legends": [{ + "encode": { + "title": { + "hover": { + "font": { "value": "Hover Font" } + } + } + } + }] + }); + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.contains("Hover Font")); + } + + #[test] + fn test_extract_title_encode_enter_state() { + let spec = json!({ + "title": { + "text": "Chart", + "encode": { + "subtitle": { + "enter": { + "font": { "value": "Subtitle Enter Font" } + } + } + } + } + }); + let fonts = extract_fonts_from_vega(&spec); + assert!(fonts.contains("Subtitle Enter Font")); + } + + #[test] + fn test_resolve_wordcloud_font() { + let spec = json!({ + "data": [{ + "name": "words", + "transform": [{ "type": "wordcloud", "font": "Pacifico" }] + }] + }); + + let fonts = extract_fonts_from_vega(&spec); + let font_strings: Vec = fonts.into_iter().collect(); + let available: HashSet = HashSet::new(); + let downloadable = |name: &str| name == "Pacifico"; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0].1, + FirstFontStatus::NeedsDownload { + name: "Pacifico".into() + } + ); + } +} diff --git a/vl-convert-rs/src/lib.rs b/vl-convert-rs/src/lib.rs index c5e7052c..1229e1bd 100644 --- a/vl-convert-rs/src/lib.rs +++ b/vl-convert-rs/src/lib.rs @@ -3,6 +3,7 @@ pub mod converter; pub mod deno_emit; pub mod deno_stubs; +pub mod extract; pub mod html; pub mod image_loading; pub mod module_loader; @@ -18,6 +19,7 @@ pub use converter::VlConverter; pub use deno_core::anyhow; pub use module_loader::import_map::VlVersion; pub use serde_json; +pub use text::{configure_font_cache, install_font}; /// V8 snapshot containing the pre-compiled deno_runtime extensions plus our /// vl_convert_runtime extension. Generated at build time for container diff --git a/vl-convert-rs/src/text.rs b/vl-convert-rs/src/text.rs index 5447a127..34a9943d 100644 --- a/vl-convert-rs/src/text.rs +++ b/vl-convert-rs/src/text.rs @@ -11,6 +11,7 @@ use usvg::{ ImageHrefResolver, }; use vl_convert_canvas2d::font_config::{font_config_to_fontdb, CustomFont, FontConfig}; +use vl_convert_fontsource::FontsourceCache; /// Monotonically increasing version counter for font configuration changes. /// Incremented each time `register_font_directory` is called. @@ -19,6 +20,8 @@ pub static FONT_CONFIG_VERSION: AtomicU64 = AtomicU64::new(0); lazy_static! { pub static ref USVG_OPTIONS: Mutex> = Mutex::new(init_usvg_options()); pub static ref FONT_CONFIG: Mutex = Mutex::new(build_default_font_config()); + pub static ref FONTSOURCE_CACHE: FontsourceCache = + FontsourceCache::new(None, None).expect("Failed to initialize FontsourceCache"); } const LIBERATION_SANS_REGULAR: &[u8] = @@ -291,3 +294,139 @@ pub fn register_font_directory(dir: &str) -> Result<(), anyhow::Error> { Ok(()) } + +/// Result of attempting to register a font directory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RegisterResult { + /// The directory was newly registered. + Registered, + /// The directory was already registered. + AlreadyRegistered, + /// The directory does not exist or contains no `.ttf` files. + DirectoryMissing, +} + +/// Register a font directory if it has not already been registered and +/// contains at least one `.ttf` file. +/// +/// This function acquires the `FONT_CONFIG` and `USVG_OPTIONS` locks +/// internally. It is intended to be called from within +/// `FONTSOURCE_CACHE.with_cache_lock(...)` so that the filesystem state +/// is stable while we check for TTF files. +pub fn register_font_directory_if_new(dir: &str) -> Result { + let path = PathBuf::from(dir); + + // Hold FONT_CONFIG lock for the entire check+register sequence to prevent + // duplicate entries from concurrent callers. + let mut font_config = FONT_CONFIG + .lock() + .map_err(|err| anyhow!("Failed to acquire font config lock: {err}"))?; + + if font_config.font_dirs.contains(&path) { + return Ok(RegisterResult::AlreadyRegistered); + } + + // Check directory exists and has at least one .ttf file + if !path.is_dir() { + return Ok(RegisterResult::DirectoryMissing); + } + let has_ttf = std::fs::read_dir(&path) + .map(|entries| { + entries.filter_map(|e| e.ok()).any(|e| { + e.path() + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("ttf")) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + if !has_ttf { + return Ok(RegisterResult::DirectoryMissing); + } + + // Register the directory (still under the same lock) + font_config.font_dirs.push(path); + drop(font_config); + + { + let mut opts = USVG_OPTIONS + .lock() + .map_err(|err| anyhow!("Failed to acquire usvg options lock: {err}"))?; + let font_db = Arc::make_mut(&mut opts.fontdb); + font_db.load_fonts_dir(dir); + setup_default_fonts(font_db); + } + + FONT_CONFIG_VERSION.fetch_add(1, Ordering::Release); + + Ok(RegisterResult::Registered) +} + +/// Fetch a font from Fontsource, register it with fontdb, and handle stale-cache recovery. +/// +/// Returns the `FetchOutcome` on success. If the cache directory is missing after +/// the initial fetch (stale cache), performs a forced re-download and retries registration. +pub(crate) async fn fetch_and_register_font( + family: &str, +) -> Result { + let mut outcome = FONTSOURCE_CACHE.fetch(family).await?; + let dir_str = outcome + .path + .to_str() + .ok_or_else(|| anyhow!("Font path is not valid UTF-8"))? + .to_string(); + + let result = FONTSOURCE_CACHE.with_cache_lock(|| register_font_directory_if_new(&dir_str))?; + let result = result?; + + if result == RegisterResult::DirectoryMissing { + // Stale cache — force re-download + outcome = FONTSOURCE_CACHE.refetch(family).await?; + let dir_str = outcome + .path + .to_str() + .ok_or_else(|| anyhow!("Font path is not valid UTF-8"))? + .to_string(); + + let result = + FONTSOURCE_CACHE.with_cache_lock(|| register_font_directory_if_new(&dir_str))?; + let result = result?; + + if result == RegisterResult::DirectoryMissing { + return Err(anyhow!( + "Font directory for '{}' is missing after re-download", + family + )); + } + } + + Ok(outcome) +} + +/// Download and install a font by family name from Fontsource. +/// +/// Uses the global `FONTSOURCE_CACHE` to fetch the font, then registers +/// the font directory in the fontdb. If the directory appears missing +/// after a cache hit (stale cache), performs a forced re-download. +pub async fn install_font(family: &str) -> Result<(), anyhow::Error> { + let outcome = fetch_and_register_font(family).await?; + + // Evict LRU fonts if cache limit is set and a download occurred + if outcome.downloaded { + let max_bytes = FONTSOURCE_CACHE.max_cache_bytes(); + if max_bytes > 0 { + let exempt = HashSet::from([outcome.font_id.clone()]); + FONTSOURCE_CACHE.evict_lru_until_size(max_bytes, &exempt)?; + } + } + + Ok(()) +} + +/// Configure the maximum size of the Fontsource font cache. +/// +/// Pass `None` or `Some(0)` to disable cache size limits (unbounded). +pub fn configure_font_cache(max_cache_bytes: Option) { + FONTSOURCE_CACHE.set_max_cache_bytes(max_cache_bytes.unwrap_or(0)); +} diff --git a/vl-convert-rs/tests/test_extract.rs b/vl-convert-rs/tests/test_extract.rs new file mode 100644 index 00000000..4982fb78 --- /dev/null +++ b/vl-convert-rs/tests/test_extract.rs @@ -0,0 +1,266 @@ +use std::collections::HashSet; +use vl_convert_rs::extract::{ + extract_fonts_from_vega, parse_css_font_family, resolve_first_fonts, FirstFontStatus, + FontFamilyEntry, +}; + +#[test] +fn test_css_parser_edge_cases() { + // Empty string + assert!(parse_css_font_family("").is_empty()); + + // Only whitespace + assert!(parse_css_font_family(" ").is_empty()); + + // Only commas + assert!(parse_css_font_family(",,,").is_empty()); + + // Quoted font with commas inside — parser splits on commas naively, + // which is acceptable since real font families never contain commas. + // This test documents the behavior rather than asserting ideal parsing. + let entries = parse_css_font_family("'Font, With Comma', serif"); + assert_eq!(entries.len(), 3); // 'Font | With Comma' | serif + + // Double-quoted + let entries = parse_css_font_family(r#""Playfair Display", sans-serif"#); + assert_eq!(entries.len(), 2); + assert!(matches!(&entries[0], FontFamilyEntry::Named(n) if n == "Playfair Display")); + assert!(matches!(&entries[1], FontFamilyEntry::Generic(g) if g == "sans-serif")); + + // wf_standard-font (real-world non-standard name) + let entries = parse_css_font_family("wf_standard-font, sans-serif"); + assert_eq!(entries.len(), 2); + assert!(matches!(&entries[0], FontFamilyEntry::Named(n) if n == "wf_standard-font")); +} + +#[test] +fn test_extraction_from_vega_fixture() { + // A comprehensive Vega spec with fonts in multiple locations + let spec: serde_json::Value = serde_json::json!({ + "config": { + "title": {"font": "Playfair Display"}, + "axis": {"labelFont": "Open Sans", "titleFont": "Lato"}, + "legend": {"labelFont": "Merriweather"}, + "text": {"font": "Roboto"} + }, + "marks": [ + { + "type": "text", + "encode": { + "enter": { + "font": {"value": "Source Sans Pro"} + } + } + }, + { + "type": "group", + "marks": [ + { + "type": "text", + "encode": { + "update": { + "font": {"value": "Montserrat"} + } + } + } + ] + } + ], + "axes": [ + {"orient": "bottom", "labelFont": "Inter"} + ], + "legends": [ + {"titleFont": "Oswald"} + ], + "title": {"text": "My Chart", "font": "Raleway", "subtitleFont": "PT Sans"} + }); + + let fonts = extract_fonts_from_vega(&spec); + + assert!(fonts.contains("Roboto"), "Text mark config font missing"); + assert!(fonts.contains("Playfair Display"), "Title font missing"); + assert!(fonts.contains("Open Sans"), "Axis labelFont missing"); + assert!(fonts.contains("Lato"), "Axis titleFont missing"); + assert!(fonts.contains("Merriweather"), "Legend labelFont missing"); + assert!(fonts.contains("Source Sans Pro"), "Mark enter font missing"); + assert!( + fonts.contains("Montserrat"), + "Nested group mark font missing" + ); + assert!(fonts.contains("Inter"), "Axes labelFont missing"); + assert!(fonts.contains("Oswald"), "Legends titleFont missing"); + assert!(fonts.contains("Raleway"), "Title font missing"); + assert!(fonts.contains("PT Sans"), "Subtitle font missing"); +} + +#[test] +fn test_extraction_with_css_fallback_chains() { + // Fonts specified as CSS fallback chains + let spec: serde_json::Value = serde_json::json!({ + "config": { + "text": { "font": "Benton Gothic, Roboto, sans-serif" } + } + }); + + let fonts = extract_fonts_from_vega(&spec); + + // Should contain the raw font string with the full chain + assert!(fonts.contains("Benton Gothic, Roboto, sans-serif")); +} + +#[test] +fn test_resolve_first_font_unavailable() { + // First font is not available or downloadable → Unavailable + let font_strings = vec!["Benton Gothic, Roboto, sans-serif".to_string()]; + let available: HashSet = HashSet::new(); + let downloadable = |family: &str| -> bool { family == "Roboto" }; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + ( + "Benton Gothic, Roboto, sans-serif".to_string(), + FirstFontStatus::Unavailable { + name: "Benton Gothic".to_string() + } + ) + ); +} + +#[test] +fn test_resolve_first_font_available() { + // First font is locally available → Available + let font_strings = vec!["Arial, Roboto, sans-serif".to_string()]; + let available: HashSet = HashSet::from(["Arial".to_string()]); + let downloadable = |_family: &str| -> bool { true }; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + ( + "Arial, Roboto, sans-serif".to_string(), + FirstFontStatus::Available { + name: "Arial".to_string() + } + ) + ); +} + +#[test] +fn test_resolve_first_font_needs_download() { + // First font is downloadable → NeedsDownload + let font_strings = vec!["Roboto, sans-serif".to_string()]; + let available: HashSet = HashSet::new(); + let downloadable = |family: &str| -> bool { family == "Roboto" }; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + ( + "Roboto, sans-serif".to_string(), + FirstFontStatus::NeedsDownload { + name: "Roboto".to_string() + } + ) + ); +} + +#[test] +fn test_resolve_first_font_generic() { + // First font is a generic keyword → Generic + let font_strings = vec!["sans-serif".to_string()]; + let available: HashSet = HashSet::new(); + let downloadable = |_family: &str| -> bool { false }; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + ("sans-serif".to_string(), FirstFontStatus::Generic) + ); +} + +#[test] +fn test_resolve_deduplicates_font_strings() { + // Duplicate font strings should be deduplicated + let font_strings = vec![ + "Roboto, sans-serif".to_string(), + "Roboto, sans-serif".to_string(), + "Open Sans, serif".to_string(), + ]; + let available: HashSet = HashSet::new(); + let downloadable = |family: &str| -> bool { family == "Roboto" || family == "Open Sans" }; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 2); + assert_eq!( + result[0], + ( + "Roboto, sans-serif".to_string(), + FirstFontStatus::NeedsDownload { + name: "Roboto".to_string() + } + ) + ); + assert_eq!( + result[1], + ( + "Open Sans, serif".to_string(), + FirstFontStatus::NeedsDownload { + name: "Open Sans".to_string() + } + ) + ); +} + +#[test] +fn test_resolve_nothing_downloadable() { + // First font is not available or downloadable → Unavailable + let font_strings = vec!["Benton Gothic, Proprietary Font, sans-serif".to_string()]; + let available: HashSet = HashSet::new(); + let downloadable = |_family: &str| -> bool { false }; + + let result = resolve_first_fonts(&font_strings, &available, downloadable); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + ( + "Benton Gothic, Proprietary Font, sans-serif".to_string(), + FirstFontStatus::Unavailable { + name: "Benton Gothic".to_string() + } + ) + ); +} + +#[test] +fn test_extraction_wordcloud_transform() { + let spec: serde_json::Value = serde_json::json!({ + "data": [ + { + "name": "table", + "transform": [ + { + "type": "wordcloud", + "font": "Lobster" + } + ] + } + ] + }); + + let fonts = extract_fonts_from_vega(&spec); + assert!( + fonts.contains("Lobster"), + "Wordcloud font should be extracted" + ); +} diff --git a/vl-convert-rs/thirdparty_rust.yaml b/vl-convert-rs/thirdparty_rust.yaml index b210d407..5082ca19 100644 --- a/vl-convert-rs/thirdparty_rust.yaml +++ b/vl-convert-rs/thirdparty_rust.yaml @@ -1,4 +1,4 @@ -root_name: vl-convert-rs, vl-convert-canvas2d, vl-convert-canvas2d-deno, vl-convert, vl-convert-python, vl-convert-vendor +root_name: vl-convert-rs, vl-convert-canvas2d, vl-convert-canvas2d-deno, vl-convert-fontsource, vl-convert, vl-convert-python, vl-convert-vendor third_party_libraries: - package_name: adler2 package_version: 2.0.1 @@ -18317,6 +18317,34 @@ third_party_libraries: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +- package_name: dashmap + package_version: 6.1.0 + repository: https://github.com/xacrimon/dashmap + license: MIT + licenses: + - license: MIT + text: | + MIT License + + Copyright (c) 2019 Acrimon + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -25590,6 +25618,40 @@ third_party_libraries: DEALINGS IN THE SOFTWARE. - license: Apache-2.0 text: " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" +- package_name: fs4 + package_version: 0.13.1 + repository: https://github.com/al8n/fs4-rs + license: MIT OR Apache-2.0 + licenses: + - license: MIT + text: | + Copyright (c) 2015 The Rust Project Developers + + Permission is hereby granted, free of charge, to any + person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the + Software without restriction, including without + limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software + is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice + shall be included in all copies or substantial portions + of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + - license: Apache-2.0 + text: " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" - package_name: fsevent-sys package_version: 4.1.0 repository: https://github.com/octplane/fsevent-rust/tree/master/fsevent-sys diff --git a/vl-convert/src/main.rs b/vl-convert/src/main.rs index 4adb63a2..502287f8 100644 --- a/vl-convert/src/main.rs +++ b/vl-convert/src/main.rs @@ -1,4 +1,5 @@ #![allow(clippy::uninlined_format_args)] +#![allow(clippy::too_many_arguments)] #![doc = include_str!("../README.md")] use clap::{Parser, Subcommand}; @@ -7,12 +8,30 @@ use std::io::{self, IsTerminal, Read, Write}; use std::path::{Path, PathBuf}; use std::str::FromStr; use vl_convert_rs::converter::{ - vega_to_url, vegalite_to_url, FormatLocale, Renderer, TimeFormatLocale, VgOpts, VlConverter, - VlConverterConfig, VlOpts, + vega_to_url, vegalite_to_url, FormatLocale, MissingFontsPolicy, Renderer, TimeFormatLocale, + VgOpts, VlConverter, VlConverterConfig, VlOpts, }; use vl_convert_rs::module_loader::import_map::VlVersion; use vl_convert_rs::text::register_font_directory; -use vl_convert_rs::{anyhow, anyhow::bail}; +use vl_convert_rs::{anyhow, anyhow::bail, install_font}; + +#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)] +enum MissingFontsArg { + #[default] + Fallback, + Warn, + Error, +} + +impl MissingFontsArg { + fn to_missing_fonts_policy(self) -> MissingFontsPolicy { + match self { + MissingFontsArg::Fallback => MissingFontsPolicy::Fallback, + MissingFontsArg::Warn => MissingFontsPolicy::Warn, + MissingFontsArg::Error => MissingFontsPolicy::Error, + } + } +} const DEFAULT_VL_VERSION: &str = "6.4"; const DEFAULT_CONFIG_PATH: &str = "~/.config/vl-convert/config.json"; @@ -33,6 +52,19 @@ struct Cli { #[arg(long, global = true)] filesystem_root: Option, + /// Install a font by family name from the Fontsource catalog before conversion. + /// May be specified multiple times. + #[arg(long, global = true)] + install_font: Vec, + + /// Automatically download missing fonts from the Fontsource catalog. + #[arg(long, global = true)] + auto_install_fonts: bool, + + /// Missing-font behavior: fallback silently, warn, or error. + #[arg(long, global = true, value_enum, default_value_t = MissingFontsArg::Fallback)] + missing_fonts: MissingFontsArg, + #[command(subcommand)] command: Commands, } @@ -570,11 +602,15 @@ async fn main() -> Result<(), anyhow::Error> { mut allow_http_access, no_http_access, filesystem_root, + install_font: install_font_families, + auto_install_fonts, + missing_fonts: missing_fonts_arg, command, } = Cli::parse(); if no_http_access { allow_http_access = false; } + let missing_fonts = missing_fonts_arg.to_missing_fonts_policy(); use crate::Commands::*; match command { Vl2vg { @@ -586,6 +622,7 @@ async fn main() -> Result<(), anyhow::Error> { pretty, show_warnings, } => { + install_fonts(&install_font_families).await?; vl_2_vg( input_vegalite_file.as_deref(), output_vega_file.as_deref(), @@ -596,6 +633,8 @@ async fn main() -> Result<(), anyhow::Error> { show_warnings, allow_http_access, filesystem_root.clone(), + auto_install_fonts, + missing_fonts, ) .await? } @@ -612,6 +651,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; vl_2_svg( input.as_deref(), output.as_deref(), @@ -624,6 +664,8 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, + missing_fonts, ) .await? } @@ -642,6 +684,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; vl_2_png( input.as_deref(), output.as_deref(), @@ -656,6 +699,8 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, + missing_fonts, ) .await? } @@ -674,6 +719,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; vl_2_jpeg( input.as_deref(), output.as_deref(), @@ -688,6 +734,8 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, + missing_fonts, ) .await? } @@ -704,6 +752,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; vl_2_pdf( input.as_deref(), output.as_deref(), @@ -716,6 +765,8 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, + missing_fonts, ) .await? } @@ -740,6 +791,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, renderer, } => { + install_fonts(&install_font_families).await?; // Initialize converter let vl_str = read_input_string(input.as_deref())?; let vl_spec: serde_json::Value = serde_json::from_str(&vl_str)?; @@ -778,6 +830,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; vg_2_svg( input.as_deref(), output.as_deref(), @@ -786,6 +839,8 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, + missing_fonts, ) .await? } @@ -800,6 +855,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; vg_2_png( input.as_deref(), output.as_deref(), @@ -810,6 +866,8 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, + missing_fonts, ) .await? } @@ -824,6 +882,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; vg_2_jpeg( input.as_deref(), output.as_deref(), @@ -834,6 +893,8 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, + missing_fonts, ) .await? } @@ -846,6 +907,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; vg_2_pdf( input.as_deref(), output.as_deref(), @@ -854,6 +916,8 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, allow_http_access, filesystem_root.clone(), + auto_install_fonts, + missing_fonts, ) .await? } @@ -875,6 +939,7 @@ async fn main() -> Result<(), anyhow::Error> { time_format_locale, renderer, } => { + install_fonts(&install_font_families).await?; // Initialize converter let vg_str = read_input_string(input.as_deref())?; let vg_spec: serde_json::Value = serde_json::from_str(&vg_str)?; @@ -909,9 +974,15 @@ async fn main() -> Result<(), anyhow::Error> { allowed_base_url, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; let svg = read_input_string(input.as_deref())?; - let converter = - build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url)?; + let converter = build_converter( + allow_http_access, + filesystem_root.clone(), + allowed_base_url, + auto_install_fonts, + missing_fonts, + )?; let png_data = converter.svg_to_png(&svg, scale, Some(ppi))?; write_output_binary(output.as_deref(), &png_data, "PNG")?; } @@ -924,9 +995,15 @@ async fn main() -> Result<(), anyhow::Error> { allowed_base_url, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; let svg = read_input_string(input.as_deref())?; - let converter = - build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url)?; + let converter = build_converter( + allow_http_access, + filesystem_root.clone(), + allowed_base_url, + auto_install_fonts, + missing_fonts, + )?; let jpeg_data = converter.svg_to_jpeg(&svg, scale, Some(quality))?; write_output_binary(output.as_deref(), &jpeg_data, "JPEG")?; } @@ -937,9 +1014,15 @@ async fn main() -> Result<(), anyhow::Error> { allowed_base_url, } => { register_font_dir(font_dir)?; + install_fonts(&install_font_families).await?; let svg = read_input_string(input.as_deref())?; - let converter = - build_converter(allow_http_access, filesystem_root.clone(), allowed_base_url)?; + let converter = build_converter( + allow_http_access, + filesystem_root.clone(), + allowed_base_url, + auto_install_fonts, + missing_fonts, + )?; let pdf_data = converter.svg_to_pdf(&svg)?; write_output_binary(output.as_deref(), &pdf_data, "PDF")?; } @@ -957,6 +1040,13 @@ fn register_font_dir(dir: Option) -> Result<(), anyhow::Error> { Ok(()) } +async fn install_fonts(fonts: &[String]) -> Result<(), anyhow::Error> { + for family in fonts { + install_font(family).await?; + } + Ok(()) +} + fn parse_vl_version(vl_version: &str) -> Result { VlVersion::from_str(vl_version) .map_err(|_| anyhow::anyhow!("Invalid or unsupported Vega-Lite version: {vl_version}")) @@ -966,11 +1056,15 @@ fn build_converter( allow_http_access: bool, filesystem_root: Option, allowed_base_urls: Option>, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result { let config = VlConverterConfig { allow_http_access, filesystem_root: filesystem_root.map(PathBuf::from), allowed_base_urls, + auto_install_fonts, + missing_fonts, ..Default::default() }; @@ -1262,6 +1356,8 @@ async fn vl_2_vg( show_warnings: bool, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1276,7 +1372,13 @@ async fn vl_2_vg( let config = read_config_json(config)?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let vega_json = match converter @@ -1325,6 +1427,8 @@ async fn vg_2_svg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1336,7 +1440,13 @@ async fn vg_2_svg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let svg = match converter @@ -1373,6 +1483,8 @@ async fn vg_2_png( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1384,7 +1496,13 @@ async fn vg_2_png( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let png_data = match converter @@ -1423,6 +1541,8 @@ async fn vg_2_jpeg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1434,7 +1554,13 @@ async fn vg_2_jpeg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let jpeg_data = match converter @@ -1470,6 +1596,8 @@ async fn vg_2_pdf( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Read input file let vega_str = read_input_string(input)?; @@ -1481,7 +1609,13 @@ async fn vg_2_pdf( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let pdf_data = match converter @@ -1520,6 +1654,8 @@ async fn vl_2_svg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1537,7 +1673,13 @@ async fn vl_2_svg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let svg = match converter @@ -1582,6 +1724,8 @@ async fn vl_2_png( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1599,7 +1743,13 @@ async fn vl_2_png( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let png_data = match converter @@ -1646,6 +1796,8 @@ async fn vl_2_jpeg( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1663,7 +1815,13 @@ async fn vl_2_jpeg( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let jpeg_data = match converter @@ -1708,6 +1866,8 @@ async fn vl_2_pdf( time_format_locale: Option, allow_http_access: bool, filesystem_root: Option, + auto_install_fonts: bool, + missing_fonts: MissingFontsPolicy, ) -> Result<(), anyhow::Error> { // Parse version let vl_version = parse_vl_version(vl_version)?; @@ -1725,7 +1885,13 @@ async fn vl_2_pdf( let time_format_locale = parse_time_format_locale_option(time_format_locale.as_deref())?; // Initialize converter - let converter = build_converter(allow_http_access, filesystem_root, None)?; + let converter = build_converter( + allow_http_access, + filesystem_root, + None, + auto_install_fonts, + missing_fonts, + )?; // Perform conversion let pdf_data = match converter diff --git a/vl-convert/thirdparty_rust.yaml b/vl-convert/thirdparty_rust.yaml index b210d407..5082ca19 100644 --- a/vl-convert/thirdparty_rust.yaml +++ b/vl-convert/thirdparty_rust.yaml @@ -1,4 +1,4 @@ -root_name: vl-convert-rs, vl-convert-canvas2d, vl-convert-canvas2d-deno, vl-convert, vl-convert-python, vl-convert-vendor +root_name: vl-convert-rs, vl-convert-canvas2d, vl-convert-canvas2d-deno, vl-convert-fontsource, vl-convert, vl-convert-python, vl-convert-vendor third_party_libraries: - package_name: adler2 package_version: 2.0.1 @@ -18317,6 +18317,34 @@ third_party_libraries: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +- package_name: dashmap + package_version: 6.1.0 + repository: https://github.com/xacrimon/dashmap + license: MIT + licenses: + - license: MIT + text: | + MIT License + + Copyright (c) 2019 Acrimon + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -25590,6 +25618,40 @@ third_party_libraries: DEALINGS IN THE SOFTWARE. - license: Apache-2.0 text: " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" +- package_name: fs4 + package_version: 0.13.1 + repository: https://github.com/al8n/fs4-rs + license: MIT OR Apache-2.0 + licenses: + - license: MIT + text: | + Copyright (c) 2015 The Rust Project Developers + + Permission is hereby granted, free of charge, to any + person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the + Software without restriction, including without + limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software + is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice + shall be included in all copies or substantial portions + of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + - license: Apache-2.0 + text: " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" - package_name: fsevent-sys package_version: 4.1.0 repository: https://github.com/octplane/fsevent-rust/tree/master/fsevent-sys