From a299979870fbeba8ccc75dff774a8dbba18cf65f Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Fri, 21 Nov 2025 08:16:08 +0800 Subject: [PATCH 01/26] feat: make decoders cache refreshable by adding a new setting option --- settings.mainnet.toml | 3 +++ settings.toml | 5 ++++- src/decoder/helpers.rs | 29 ++++++++++++++++++++++++++--- src/main.rs | 12 ++++++++++++ src/types.rs | 2 ++ 5 files changed, 47 insertions(+), 4 deletions(-) diff --git a/settings.mainnet.toml b/settings.mainnet.toml index fce017f..a848391 100644 --- a/settings.mainnet.toml +++ b/settings.mainnet.toml @@ -19,6 +19,9 @@ dobs_cache_directory = "cache/dobs" # expiration time indicator for cleaning whole dobs cache, zero means never clean dobs_cache_expiration_sec = 300 +# expiration time in minutes dedicated for type_id and type_script decoder cache, zero means always update +decoders_cache_expiration_minutes = 1440 + # all deployed on-chain Spore contracts binary hash (order from new to old) # refer to: https://github.com/sporeprotocol/spore-contract/blob/master/docs/VERSIONS.md [[available_spores]] diff --git a/settings.toml b/settings.toml index 028c208..a9d5207 100644 --- a/settings.toml +++ b/settings.toml @@ -11,7 +11,7 @@ ckb_rpc = "https://testnet.ckbapp.dev/" rpc_server_address = "0.0.0.0:8090" # directory that stores decoders on hard-disk, including on-chain and off-chain binary files -decoders_cache_directory = "cache/decoders" +decoders_cache_directory = "cache/decoders/testnet" # directory that stores DOBs rendering results on hard-disk dobs_cache_directory = "cache/dobs" @@ -19,6 +19,9 @@ dobs_cache_directory = "cache/dobs" # expiration time indicator for cleaning whole dobs cache, zero means never clean dobs_cache_expiration_sec = 300 +# expiration time in minutes dedicated for type_id and type_script decoder cache, zero means always update +decoders_cache_expiration_minutes = 1440 + # all deployed on-chain Spore contracts binary hash (order from new to old) # refer to: https://github.com/sporeprotocol/spore-contract/blob/master/docs/VERSIONS.md [[available_spores]] diff --git a/src/decoder/helpers.rs b/src/decoder/helpers.rs index 4755b1f..4e0ac27 100644 --- a/src/decoder/helpers.rs +++ b/src/decoder/helpers.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{path::PathBuf, time::SystemTime}; use ckb_jsonrpc_types::Either; use ckb_sdk::{constants::TYPE_ID_CODE_HASH, rpc::ckb_indexer::Tx, traits::CellQueryOptions}; @@ -32,6 +32,29 @@ fn build_type_script_search_option(type_script: Script) -> CellQueryOptions { CellQueryOptions::new_type(type_script) } +fn file_older_than_minutes(file_path: &PathBuf, minutes: u64) -> bool { + match std::fs::metadata(file_path) { + Ok(metadata) => { + let Ok(mut duration) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) else { + return true; + }; + if let Ok(Ok(checkpoint)) = metadata + .modified() + .map(|time| time.duration_since(SystemTime::UNIX_EPOCH)) + { + duration = duration.saturating_sub(checkpoint); + } else if let Ok(Ok(checkpoint)) = metadata + .created() + .map(|time| time.duration_since(SystemTime::UNIX_EPOCH)) + { + duration = duration.saturating_sub(checkpoint); + } + duration.as_secs() / 60 >= minutes + } + Err(_) => true, + } +} + fn build_batch_search_options( type_args: &[u8; 32], available_script_ids: &[ScriptId], @@ -267,7 +290,7 @@ pub async fn parse_decoder_path( DecoderLocationType::TypeId => { let hash = decoder.hash.as_ref().ok_or(Error::DecoderHashNotFound)?; decoder_path.push(format!("type_id_{}.bin", hex::encode(hash))); - if !decoder_path.exists() { + if file_older_than_minutes(&decoder_path, settings.decoders_cache_expiration_minutes) { let decoder_search_option = build_type_id_search_option(hash.clone().into()); let decoder_binary = fetch_decoder_binary(rpc, decoder_search_option).await?; std::fs::write(decoder_path.clone(), decoder_binary) @@ -284,7 +307,7 @@ pub async fn parse_decoder_path( "type_script_{}.bin", hex::encode(script.calc_script_hash().raw_data()) )); - if !decoder_path.exists() { + if file_older_than_minutes(&decoder_path, settings.decoders_cache_expiration_minutes) { let decoder_search_option = build_type_script_search_option(script); let decoder_binary = fetch_decoder_binary(rpc, decoder_search_option).await?; std::fs::write(decoder_path.clone(), decoder_binary) diff --git a/src/main.rs b/src/main.rs index 375225f..cd6a5ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,18 @@ async fn main() { "server settings: {}", serde_json::to_string_pretty(&settings).unwrap() ); + + tracing::info!("ensuring cache directories exist"); + fs::create_dir_all(&settings.decoders_cache_directory) + .expect("failed to create decoders cache directory"); + fs::create_dir_all(&settings.dobs_cache_directory) + .expect("failed to create DOBs cache directory"); + tracing::info!( + "decoders cache directory: {:?}", + settings.decoders_cache_directory + ); + tracing::info!("DOBs cache directory: {:?}", settings.dobs_cache_directory); + let rpc_server_address = settings.rpc_server_address.clone(); let cache_expiration = settings.dobs_cache_expiration_sec; let decoder = decoder::DOBDecoder::new(settings); diff --git a/src/types.rs b/src/types.rs index fb72861..b389faf 100644 --- a/src/types.rs +++ b/src/types.rs @@ -94,6 +94,7 @@ impl From for ErrorObjectOwned { #[derive(Deserialize)] #[cfg_attr(test, derive(serde::Serialize, PartialEq, Debug))] pub struct ClusterDescriptionField { + #[allow(dead_code)] pub description: String, pub dob: DOBClusterFormat, } @@ -257,6 +258,7 @@ pub struct Settings { pub decoders_cache_directory: PathBuf, pub dobs_cache_directory: PathBuf, pub dobs_cache_expiration_sec: u64, + pub decoders_cache_expiration_minutes: u64, pub onchain_decoder_deployment: Vec, pub available_spores: Vec, pub available_clusters: Vec, From 37bdec4a53af9d5a1edaeba798e780d14e211bf8 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Thu, 7 Aug 2025 22:19:30 +0800 Subject: [PATCH 02/26] feat: add new method for the extraction of inner svg of dob render output --- Cargo.lock | 57 ++++++++---- Cargo.toml | 4 +- settings.mainnet.toml | 3 + settings.toml | 3 + src/client.rs | 178 ++++++++++++++++++++++++++++++++++++ src/decoder/helpers.rs | 3 +- src/lib.rs | 1 + src/main.rs | 1 + src/server.rs | 21 ++++- src/svg.rs | 203 +++++++++++++++++++++++++++++++++++++++++ src/types.rs | 23 ++++- 11 files changed, 476 insertions(+), 21 deletions(-) create mode 100644 src/svg.rs diff --git a/Cargo.lock b/Cargo.lock index 3fcfc30..8eebda7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -89,9 +89,9 @@ checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" [[package]] name = "base64" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bech32" @@ -762,6 +762,7 @@ dependencies = [ name = "dob-decoder-server" version = "0.1.0" dependencies = [ + "base64 0.22.1", "ckb-hash", "ckb-jsonrpc-types", "ckb-sdk", @@ -771,6 +772,7 @@ dependencies = [ "hex", "jsonrpc-core", "jsonrpsee", + "lazy-regex", "lazy_static", "reqwest 0.12.4", "serde", @@ -1460,6 +1462,29 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "lazy-regex" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60c7310b93682b36b98fa7ea4de998d3463ccbebd94d935d6b48ba5b6ffa7126" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba01db5ef81e17eb10a5e0f2109d1b3a3e29bac3070fdbd7d156bf7dbd206a1" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.57", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -1691,9 +1716,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "opaque-debug" @@ -2028,14 +2053,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.4" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.6", - "regex-syntax 0.8.3", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", ] [[package]] @@ -2049,13 +2074,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.6" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.3", + "regex-syntax 0.8.5", ] [[package]] @@ -2066,9 +2091,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" @@ -2116,7 +2141,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "566cafdd92868e0939d3fb961bd0dc25fcfaaed179291093b3d43e6b3150ea10" dependencies = [ - "base64 0.22.0", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -2207,7 +2232,7 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" dependencies = [ - "base64 0.22.0", + "base64 0.22.1", "rustls-pki-types", ] diff --git a/Cargo.toml b/Cargo.toml index 2edf7a2..80d4d57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +base64 = "0.22.1" ckb-sdk = "3.2.0" ckb-types = "0.116.1" ckb-jsonrpc-types = "0.116.1" @@ -17,7 +18,8 @@ reqwest = { version = "0.12.4", features = ["json"] } jsonrpc-core = "18.0" serde = { version = "1.0", features = ["serde_derive"] } futures = "0.3" -lazy_static = { version = "1.4" } +lazy_static = "1.4" +lazy-regex = "3.1.0" ckb-vm = { version = "0.24", features = ["asm"] } spore-types = { git = "https://github.com/sporeprotocol/spore-contract", rev = "81315ca" } diff --git a/settings.mainnet.toml b/settings.mainnet.toml index a848391..6360d57 100644 --- a/settings.mainnet.toml +++ b/settings.mainnet.toml @@ -7,6 +7,9 @@ protocol_versions = [ # connect to the RPC of CKB node ckb_rpc = "https://mainnet.ckb.dev/" +# connect to the image fetcher service +image_fetcher_url = { btcfs = "https://mempool.space/api/tx/", ipfs = "https://ipfs.io/ipfs/" } + # address that rpc server running at in case of standalone server mode rpc_server_address = "0.0.0.0:8090" diff --git a/settings.toml b/settings.toml index a9d5207..6ecf2ee 100644 --- a/settings.toml +++ b/settings.toml @@ -7,6 +7,9 @@ protocol_versions = [ # connect to the RPC of CKB node ckb_rpc = "https://testnet.ckbapp.dev/" +# connect to the image fetcher service +image_fetcher_url = { btcfs = "https://mempool.space/testnet/api/tx/", ipfs = "https://ipfs.io/ipfs/" } + # address that rpc server running at in case of standalone server mode rpc_server_address = "0.0.0.0:8090" diff --git a/src/client.rs b/src/client.rs index 1cd26e7..1fc12a5 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,3 +1,4 @@ +use std::collections::{HashMap, VecDeque}; use std::future::Future; use std::pin::Pin; use std::sync::atomic::{AtomicU64, Ordering}; @@ -9,7 +10,9 @@ use ckb_jsonrpc_types::{ use ckb_sdk::rpc::ckb_indexer::{Cell, Order, Pagination, SearchKey, Tx}; use ckb_types::H256; use jsonrpc_core::futures::FutureExt; +use lazy_regex::regex_replace_all; use reqwest::{Client, Url}; +use serde_json::Value; use crate::types::Error; @@ -150,3 +153,178 @@ impl RpcClient { .boxed() } } + +pub struct ImageFetchClient { + base_url: HashMap, + images_cache: VecDeque<(Url, Vec)>, + max_cache_size: usize, +} + +impl ImageFetchClient { + pub fn new(base_url: &HashMap, cache_size: usize) -> Self { + let base_url = base_url + .iter() + .map(|(k, v)| (k.clone(), Url::parse(v).expect("url"))) + .collect::>(); + Self { + base_url, + images_cache: VecDeque::new(), + max_cache_size: cache_size, + } + } + + pub async fn fetch_images(&mut self, images_uri: &[String]) -> Result>, Error> { + let mut requests = vec![]; + for uri in images_uri { + match uri.try_into()? { + URI::BTCFS(tx_hash, index) => { + let url = self + .base_url + .get("btcfs") + .ok_or(Error::FsuriNotFoundInConfig)? + .join(&tx_hash) + .expect("image url"); + let cached_image = self.images_cache.iter().find(|(v, _)| v == &url); + if let Some((_, image)) = cached_image { + requests.push(async { Ok((url, true, image.clone())) }.boxed()); + } else { + requests.push( + async move { + let image = parse_image_from_btcfs(&url, index).await?; + Ok((url, false, image)) + } + .boxed(), + ); + } + } + URI::IPFS(cid) => { + let url = self + .base_url + .get("ipfs") + .ok_or(Error::FsuriNotFoundInConfig)? + .join(&cid) + .expect("image url"); + let cached_image = self.images_cache.iter().find(|(v, _)| v == &url); + if let Some((_, image)) = cached_image { + requests.push(async { Ok((url, true, image.clone())) }.boxed()); + } else { + requests.push( + async move { + let image = reqwest::get(url.clone()) + .await + .map_err(|e| Error::FetchFromIpfsError(e.to_string()))? + .bytes() + .await + .map_err(|e| Error::FetchFromIpfsError(e.to_string()))? + .to_vec(); + Ok((url, false, image)) + } + .boxed(), + ); + } + } + } + } + let mut images = vec![]; + let responses = futures::future::join_all(requests).await; + for response in responses { + let (url, from_cache, result) = response?; + images.push(result.to_vec()); + if !from_cache { + self.images_cache.push_back((url, result)); + if self.images_cache.len() > self.max_cache_size { + self.images_cache.pop_front(); + } + } + } + Ok(images) + } +} + +#[allow(clippy::upper_case_acronyms)] +enum URI { + BTCFS(String, usize), + IPFS(String), +} + +impl TryFrom<&String> for URI { + type Error = Error; + + fn try_from(uri: &String) -> Result { + if uri.starts_with("btcfs://") { + let body = uri.chars().skip("btcfs://".len()).collect::(); + let parts: Vec<&str> = body.split('i').collect::>(); + if parts.len() != 2 { + return Err(Error::InvalidOnchainFsuriFormat); + } + let tx_hash = parts[0].to_string(); + let index = parts[1] + .parse() + .map_err(|_| Error::InvalidOnchainFsuriFormat)?; + Ok(URI::BTCFS(tx_hash, index)) + } else if uri.starts_with("ipfs://") { + let hash = uri.chars().skip("ipfs://".len()).collect::(); + Ok(URI::IPFS(hash)) + } else { + Err(Error::InvalidOnchainFsuriFormat) + } + } +} + +async fn parse_image_from_btcfs(url: &Url, index: usize) -> Result, Error> { + // parse btc transaction + let btc_tx = reqwest::get(url.clone()) + .await + .map_err(|e| Error::FetchFromBtcNodeError(e.to_string()))? + .json::() + .await + .map_err(|e| Error::FetchFromBtcNodeError(e.to_string()))?; + let vin = btc_tx + .get("vin") + .ok_or(Error::InvalidBtcTransactionFormat( + "vin not found".to_string(), + ))? + .as_array() + .ok_or(Error::InvalidBtcTransactionFormat( + "vin not an array".to_string(), + ))? + .first() + .ok_or(Error::InvalidBtcTransactionFormat( + "vin is empty".to_string(), + ))?; + let mut witness = vin + .get("inner_witnessscript_asm") + .ok_or(Error::InvalidBtcTransactionFormat( + "inner_witnessscript_asm not found".to_string(), + ))? + .as_str() + .ok_or(Error::InvalidBtcTransactionFormat( + "inner_witnessscript_asm not a string".to_string(), + ))? + .to_owned(); + + // parse inscription body + let mut images = vec![]; + let header = "OP_IF OP_PUSHBYTES_3 444f42 OP_PUSHBYTES_1 01 OP_PUSHBYTES_9 696d6167652f706e67 OP_0 OP_PUSHDATA2 "; + while let (Some(start), Some(end)) = (witness.find("OP_IF"), witness.find("OP_ENDIF")) { + let inscription = &witness[start..end + "OP_ENDIF".len()]; + if !inscription.contains(header) { + return Err(Error::InvalidInscriptionFormat); + } + let base_removed = inscription.replace(header, ""); + let hexed = regex_replace_all!(r#"\s?OP\_\w+\s?"#, &base_removed, ""); + let image = + hex::decode(hexed.as_bytes()).map_err(|_| Error::InvalidInscriptionContentHexFormat)?; + images.push(image); + witness = witness[end + "OP_ENDIF".len()..].to_owned(); + } + if images.is_empty() { + return Err(Error::EmptyInscriptionContent); + } + + let image = images + .get(index) + .cloned() + .ok_or(Error::ExceededInscriptionIndex)?; + Ok(image) +} diff --git a/src/decoder/helpers.rs b/src/decoder/helpers.rs index 4e0ac27..481ed16 100644 --- a/src/decoder/helpers.rs +++ b/src/decoder/helpers.rs @@ -103,7 +103,8 @@ pub fn decode_spore_content(content: &[u8]) -> Result<(Value, String), Error> { return Ok((serde_json::Value::String(dna.clone()), dna)); } - let value: Value = serde_json::from_slice(content).map_err(|_| Error::DOBContentUnexpected)?; + let value: Value = serde_json::from_slice(content) + .unwrap_or(Value::String(String::from_utf8(content.to_vec()).unwrap())); let dna = match &value { serde_json::Value::String(_) => &value, serde_json::Value::Array(array) => array.first().ok_or(Error::DOBContentUnexpected)?, diff --git a/src/lib.rs b/src/lib.rs index 93f172c..250533d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,4 +4,5 @@ mod vm; pub mod client; pub mod decoder; +pub mod svg; pub mod types; diff --git a/src/main.rs b/src/main.rs index cd6a5ef..36b5e21 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use tracing_subscriber::EnvFilter; mod client; mod decoder; mod server; +mod svg; mod types; mod vm; diff --git a/src/server.rs b/src/server.rs index fdbc727..2f5ca16 100644 --- a/src/server.rs +++ b/src/server.rs @@ -4,15 +4,17 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use jsonrpsee::core::async_trait; use jsonrpsee::{proc_macros::rpc, tracing, types::error::ErrorObjectOwned}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::client::ImageFetchClient; use crate::decoder::helpers::{decode_cluster_data, decode_spore_data}; use crate::decoder::DOBDecoder; +use crate::svg::DOBSvgExtractor; use crate::types::Error; // decoding result contains rendered result from native decoder and DNA string for optional use -#[derive(Serialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct ServerDecodeResult { render_output: String, dob_content: Value, @@ -38,6 +40,9 @@ trait DecoderRpc { spore_data: String, cluster_data: String, ) -> Result; + + #[method(name = "dob_decode_svg")] + async fn decode_svg(&self, hexed_spore_id: String) -> Result; } pub struct DecoderStandaloneServer { @@ -136,6 +141,18 @@ impl DecoderRpcServer for DecoderStandaloneServer { tracing::info!("raw, result: {result}"); Ok(result) } + + async fn decode_svg(&self, hexed_spore_id: String) -> Result { + let ServerDecodeResult { + render_output, + dob_content: _, + } = serde_json::from_str(&self.decode(hexed_spore_id).await?).unwrap(); + let image_fetcher = ImageFetchClient::new(&self.decoder.setting().image_fetcher_url, 10); + let svg = DOBSvgExtractor::new(render_output, image_fetcher)? + .extract_svg() + .await?; + Ok(svg.unwrap_or(Default::default())) + } } fn trim_0x(hexed: &str) -> &str { diff --git a/src/svg.rs b/src/svg.rs new file mode 100644 index 0000000..8992ed7 --- /dev/null +++ b/src/svg.rs @@ -0,0 +1,203 @@ +use base64::{engine::general_purpose::STANDARD, Engine}; +use lazy_regex::regex; +use serde_json::Value; + +use crate::{ + client::ImageFetchClient, + types::{Error, StandardDOBOutput}, +}; + +const DOB0_TRAIT_NAME: &str = "prev.bg"; +const DOB1_TRAIT_NAME: &str = "IMAGE.0"; +const DEFAULT_SIZE: u32 = 500; + +/// Detects the MIME type of an image from its hex-encoded content by examining file signatures. +/// Returns Some(mime_type) if recognized, or None if not recognized. +pub fn detect_image_mime_type(hex_content: String) -> Option<&'static str> { + // Skip if string is too short to contain a signature and content + if hex_content.len() < 64 { + return None; + } + + // Extract just the file header (first 32 bytes should be enough for most formats) + // and convert to lowercase for consistent comparison + let header = &hex_content[..64].to_ascii_lowercase(); + + // JPEG: starts with ffd8ff + if header.starts_with("ffd8ff") { + return Some("image/jpeg"); + } + + // PNG: starts with 89504e47 (‰PNG) + if header.starts_with("89504e47") { + return Some("image/png"); + } + + // GIF: starts with 474946 (GIF) + if header.starts_with("474946") { + return Some("image/gif"); + } + + // WebP: RIFF....WEBP + if header.starts_with("52494646") && header.get(16..24) == Some("57454250") { + return Some("image/webp"); + } + + // BMP: starts with 424d (BM) + if header.starts_with("424d") { + return Some("image/bmp"); + } + + // SVG: starts with , +} + +impl DOBSvgExtractor { + pub fn new(dob_render_output: String, fetcher: ImageFetchClient) -> Result { + let parsed_dob: Vec = + serde_json::from_str(&dob_render_output).map_err(|_| Error::DOBRenderOutputInvalid)?; + Ok(Self { + fetcher, + parsed_dob, + }) + } + + pub async fn extract_svg(mut self) -> Result, Error> { + if let Some(svg) = self.extract_svg_from_legacy_dob0().await? { + return Ok(Some(svg)); + } + + if let Some(dob1_svg) = self.extract_svg_from_dob1().await? { + return Ok(Some(dob1_svg)); + } + + Ok(None) + } + + async fn extract_svg_from_legacy_dob0(&mut self) -> Result, Error> { + let fsurl = self.parsed_dob.iter().find_map(|dob| { + if dob.name == DOB0_TRAIT_NAME { + if let Some(dob_trait) = dob.traits.iter().find(|value| value.type_ == "String") { + if let Value::String(fsurl) = &dob_trait.value { + if fsurl.starts_with("btcfs://") || fsurl.starts_with("ipfs://") { + return Some(fsurl); + } + } + } + } + None + }); + if let Some(dob0_fsurl) = fsurl { + let image_content = self + .fetcher + .fetch_images(&[dob0_fsurl.clone()]) + .await? + .remove(0); + let Some(image_mime_type) = detect_image_mime_type(hex::encode(&image_content)) else { + return Ok(None); + }; + let image_content_base64 = STANDARD.encode(&image_content); + let svg_content = format!( + r#" + + + "# + ); + Ok(Some(svg_content)) + } else { + Ok(None) + } + } + + async fn extract_svg_from_dob1(&mut self) -> Result, Error> { + let svg = self.parsed_dob.iter().find_map(|dob| { + if dob.name == DOB1_TRAIT_NAME { + if let Some(dob_trait) = dob.traits.iter().find(|value| value.type_ == "SVG") { + if let Value::String(svg) = &dob_trait.value { + return Some(svg); + } + } + } + None + }); + if let Some(svg) = svg { + Ok(self.replace_svg_fsurls(svg.clone()).await?) + } else { + Ok(None) + } + } + + async fn replace_svg_fsurls(&mut self, svg_content: String) -> Result, Error> { + // Create regex patterns to match btcfs:// and ipfs:// URLs in href attributes + let btcfs_pattern = regex!(r#"href="btcfs://([^"]+)""#); + let ipfs_pattern = regex!(r#"href="ipfs://([^"]+)""#); + + let mut processed_svg = svg_content.clone(); + + // Find all btcfs URLs + let btcfs_urls: Vec = btcfs_pattern + .captures_iter(&svg_content) + .map(|cap| format!("btcfs://{}", &cap[1])) + .collect(); + + // Find all ipfs URLs + let ipfs_urls: Vec = ipfs_pattern + .captures_iter(&svg_content) + .map(|cap| format!("ipfs://{}", &cap[1])) + .collect(); + + // Combine all URLs to fetch + let mut all_urls = btcfs_urls; + all_urls.extend(ipfs_urls); + + if all_urls.is_empty() { + return Ok(None); + } + + // Fetch all images + let image_contents = self.fetcher.fetch_images(&all_urls).await?; + + // Replace URLs with base64 content + for (i, url) in all_urls.iter().enumerate() { + let image_content = &image_contents[i]; + let Some(mime_type) = detect_image_mime_type(hex::encode(image_content)) else { + continue; + }; + let base64_content = STANDARD.encode(image_content); + let data_url = format!("data:{};base64,{}", mime_type, base64_content); + + // Replace the URL in the SVG + let old_href = format!("href=\"{}\"", url); + let new_href = format!("href=\"{}\"", data_url); + processed_svg = processed_svg.replace(&old_href, &new_href); + } + + Ok(Some(processed_svg)) + } +} diff --git a/src/types.rs b/src/types.rs index b389faf..f11de2d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{collections::HashMap, path::PathBuf}; use ckb_jsonrpc_types::Script; use ckb_types::{core::ScriptHashType, H256}; @@ -75,6 +75,26 @@ pub enum Error { DecoderScriptNotFound, #[error("decoders configured in cluster are empty, please check your cluster config")] DecoderChainIsEmpty, + #[error("BTC node responsed badly with error: {0}")] + FetchFromBtcNodeError(String), + #[error("BTC transaction format has broken: {0}")] + InvalidBtcTransactionFormat(String), + #[error("Inscription format broken")] + InvalidInscriptionFormat, + #[error("Inscription content must be hex format")] + InvalidInscriptionContentHexFormat, + #[error("Inscription content must be filled")] + EmptyInscriptionContent, + #[error("Inscription index flag exceeded")] + ExceededInscriptionIndex, + #[error("fs header like 'btcfs://' and 'ckbfs://' are not contained")] + InvalidOnchainFsuriFormat, + #[error("fs header like 'btcfs://' and 'ckbfs://' are not configured in config file")] + FsuriNotFoundInConfig, + #[error("IPFS Gateway responsed badly with error: {0}")] + FetchFromIpfsError(String), + #[error("DOB render output is not in format of DOB protocol")] + DOBRenderOutputInvalid, } pub enum Dob<'a> { @@ -254,6 +274,7 @@ pub struct ScriptId { pub struct Settings { pub protocol_versions: Vec, pub ckb_rpc: String, + pub image_fetcher_url: HashMap, pub rpc_server_address: String, pub decoders_cache_directory: PathBuf, pub dobs_cache_directory: PathBuf, From 594a4747b6ebbb9ff54473f3781d1cecaaef2d81 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Fri, 8 Aug 2025 13:37:40 +0800 Subject: [PATCH 03/26] feat: complete test of svg extractor --- src/decoder/helpers.rs | 5 +- src/svg.rs | 15 +++--- src/tests/dob0/decoder.rs | 6 +-- src/tests/dob0/legacy_decoder.rs | 41 +++++++++++++--- src/tests/dob1/decoder.rs | 23 ++++++++- src/tests/mod.rs | 82 ++++++++++---------------------- 6 files changed, 91 insertions(+), 81 deletions(-) diff --git a/src/decoder/helpers.rs b/src/decoder/helpers.rs index 481ed16..3602a42 100644 --- a/src/decoder/helpers.rs +++ b/src/decoder/helpers.rs @@ -103,8 +103,9 @@ pub fn decode_spore_content(content: &[u8]) -> Result<(Value, String), Error> { return Ok((serde_json::Value::String(dna.clone()), dna)); } - let value: Value = serde_json::from_slice(content) - .unwrap_or(Value::String(String::from_utf8(content.to_vec()).unwrap())); + let value: Value = serde_json::from_slice(content).unwrap_or(Value::String( + String::from_utf8(content.to_vec()).expect("raw string content"), + )); let dna = match &value { serde_json::Value::String(_) => &value, serde_json::Value::Array(array) => array.first().ok_or(Error::DOBContentUnexpected)?, diff --git a/src/svg.rs b/src/svg.rs index 8992ed7..cc8840e 100644 --- a/src/svg.rs +++ b/src/svg.rs @@ -8,7 +8,7 @@ use crate::{ }; const DOB0_TRAIT_NAME: &str = "prev.bg"; -const DOB1_TRAIT_NAME: &str = "IMAGE.0"; +const DOB1_TRAIT_NAME: &str = "IMAGE"; const DEFAULT_SIZE: u32 = 500; /// Detects the MIME type of an image from its hex-encoded content by examining file signatures. @@ -124,10 +124,7 @@ impl DOBSvgExtractor { }; let image_content_base64 = STANDARD.encode(&image_content); let svg_content = format!( - r#" - - - "# + r#""# ); Ok(Some(svg_content)) } else { @@ -155,8 +152,8 @@ impl DOBSvgExtractor { async fn replace_svg_fsurls(&mut self, svg_content: String) -> Result, Error> { // Create regex patterns to match btcfs:// and ipfs:// URLs in href attributes - let btcfs_pattern = regex!(r#"href="btcfs://([^"]+)""#); - let ipfs_pattern = regex!(r#"href="ipfs://([^"]+)""#); + let btcfs_pattern = regex!(r#"href='btcfs://([^']+)'"#); + let ipfs_pattern = regex!(r#"href='ipfs://([^']+)'"#); let mut processed_svg = svg_content.clone(); @@ -193,8 +190,8 @@ impl DOBSvgExtractor { let data_url = format!("data:{};base64,{}", mime_type, base64_content); // Replace the URL in the SVG - let old_href = format!("href=\"{}\"", url); - let new_href = format!("href=\"{}\"", data_url); + let old_href = format!("href='{}'", url); + let new_href = format!("href='{}'", data_url); processed_svg = processed_svg.replace(&old_href, &new_href); } diff --git a/src/tests/dob0/decoder.rs b/src/tests/dob0/decoder.rs index 98c3755..7c0a294 100644 --- a/src/tests/dob0/decoder.rs +++ b/src/tests/dob0/decoder.rs @@ -2,7 +2,7 @@ use ckb_types::{h256, H256}; use serde_json::{json, Value}; use crate::decoder::DOBDecoder; -use crate::tests::prepare_settings; +use crate::tests::{prepare_settings, SettingType}; use crate::types::{ ClusterDescriptionField, DOBClusterFormat, DOBClusterFormatV0, DOBDecoderFormat, DecoderLocationType, @@ -84,7 +84,7 @@ fn generate_example_dob_ingredients(onchain_decoder: bool) -> (Value, ClusterDes #[tokio::test] async fn test_fetch_and_decode_unicorn_dna() { - let settings = prepare_settings("text/plain"); + let settings = prepare_settings(SettingType::Testnet, vec!["text/plain"]); let decoder = DOBDecoder::new(settings); let (_, dna, dob_metadata) = decoder .fetch_decode_ingredients(UNICORN_SPORE_ID.into()) @@ -115,7 +115,7 @@ fn test_unicorn_json_serde() { #[tokio::test] async fn test_fetch_and_decode_example_dna() { - let settings = prepare_settings("text/plain"); + let settings = prepare_settings(SettingType::Testnet, vec!["text/plain"]); let decoder = DOBDecoder::new(settings); let (_, dna, dob_metadata) = decoder .fetch_decode_ingredients(EXAMPLE_SPORE_ID.into()) diff --git a/src/tests/dob0/legacy_decoder.rs b/src/tests/dob0/legacy_decoder.rs index 2e96379..aba892c 100644 --- a/src/tests/dob0/legacy_decoder.rs +++ b/src/tests/dob0/legacy_decoder.rs @@ -1,7 +1,9 @@ use ckb_types::{h256, H256}; +use crate::client::ImageFetchClient; use crate::decoder::{helpers::decode_spore_content, DOBDecoder}; -use crate::tests::prepare_settings; +use crate::svg::DOBSvgExtractor; +use crate::tests::{prepare_settings, SettingType}; use crate::types::{ ClusterDescriptionField, DOBClusterFormat, DOBClusterFormatV0, DOBDecoderFormat, DecoderLocationType, @@ -12,6 +14,8 @@ const EXPECTED_UNICORN_RENDER_RESULT: &str = "[{\"name\":\"wuxing_yinyang\",\"tr const EXPECTED_NERVAPE_RENDER_RESULT: &str = "[{\"name\":\"prev.type\",\"traits\":[{\"String\":\"text\"}]},{\"name\":\"prev.bg\",\"traits\":[{\"String\":\"btcfs://59e87ca177ef0fd457e87e9f93627660022cf519b531e1f4e3a6dda9e5e33827i0\"}]},{\"name\":\"prev.bgcolor\",\"traits\":[{\"String\":\"#CEBAF7\"}]},{\"name\":\"Background\",\"traits\":[{\"Number\":170}]},{\"name\":\"Suit\",\"traits\":[{\"Number\":236}]},{\"name\":\"Upper body\",\"traits\":[{\"Number\":53}]},{\"name\":\"Lower body\",\"traits\":[{\"Number\":189}]},{\"name\":\"Headwear\",\"traits\":[{\"Number\":175}]},{\"name\":\"Mask\",\"traits\":[{\"Number\":153}]},{\"name\":\"Eyewear\",\"traits\":[{\"Number\":126}]},{\"name\":\"Mouth\",\"traits\":[{\"Number\":14}]},{\"name\":\"Ears\",\"traits\":[{\"Number\":165}]},{\"name\":\"Tattoo\",\"traits\":[{\"Number\":231}]},{\"name\":\"Accessory\",\"traits\":[{\"Number\":78}]},{\"name\":\"Handheld\",\"traits\":[{\"Number\":240}]},{\"name\":\"Special\",\"traits\":[{\"Number\":70}]}]"; const NERVAPE_SPORE_ID: H256 = h256!("0x9dd9604d44d6640d1533c9f97f89438f17526e645f6c35aa08d8c7d844578580"); +const MAINNET_NERVAPE_SPORE_ID: H256 = + h256!("0xbbe57f0e7f7ca6e6c59007b28150e39c9c6f5c209493801cfc9ef125e0937ed4"); fn generate_nervape_dob_ingredients(onchain_decoder: bool) -> (Value, ClusterDescriptionField) { let nervape_content = json!({ @@ -79,11 +83,11 @@ fn generate_unicorn_dob_ingredients(onchain_decoder: bool) -> (Value, ClusterDes } async fn decode_unicorn_dna(onchain_decoder: bool) -> String { - let settings = prepare_settings("text/plain"); + let settings = prepare_settings(SettingType::Testnet, vec!["text/plain"]); let decoder = DOBDecoder::new(settings); let (unicorn_content, unicorn_metadata) = generate_unicorn_dob_ingredients(onchain_decoder); decoder - .decode_dna(&unicorn_content["dna"].as_str().unwrap(), unicorn_metadata) + .decode_dna(unicorn_content["dna"].as_str().unwrap(), unicorn_metadata) .await .expect("decode") } @@ -99,7 +103,7 @@ async fn test_decode_unicorn_dna() { #[tokio::test] async fn test_fetch_and_decode_nervape_dna() { - let settings = prepare_settings("text/plain"); + let settings = prepare_settings(SettingType::Testnet, vec!["text/plain"]); let decoder = DOBDecoder::new(settings); let (_, dna, dob_metadata) = decoder .fetch_decode_ingredients(NERVAPE_SPORE_ID.into()) @@ -116,7 +120,7 @@ async fn test_fetch_and_decode_nervape_dna() { #[tokio::test] #[should_panic = "fetch: DOBVersionUnexpected"] async fn test_fetch_onchain_dob_failed() { - let settings = prepare_settings("dob/0"); + let settings = prepare_settings(SettingType::Testnet, vec!["dob/0"]); DOBDecoder::new(settings) .fetch_decode_ingredients(NERVAPE_SPORE_ID.into()) .await @@ -164,8 +168,31 @@ fn test_decode_multiple_spore_data() { .into_iter() .enumerate() .for_each(|(i, spore_data)| { - let (_, v) = - decode_spore_content(spore_data.as_bytes()).expect(&format!("assert type index {i}")); + let (_, v) = decode_spore_content(spore_data.as_bytes()) + .unwrap_or_else(|_| panic!("assert type index {i}")); assert_eq!(v, dna, "object type comparison failed"); }); } + +#[tokio::test] +async fn test_fetch_and_decode_mainnet_nervape_dna_to_svg() { + let settings = prepare_settings(SettingType::Mainnet, vec![]); + let image_fetcher = ImageFetchClient::new(&settings.image_fetcher_url, 10); + let decoder = DOBDecoder::new(settings); + let (_, dna, dob_metadata) = decoder + .fetch_decode_ingredients(MAINNET_NERVAPE_SPORE_ID.into()) + .await + .expect("fetch"); + let render_result = decoder + .decode_dna(&dna, dob_metadata) + // array type + .await + .expect("decode"); + let svg_extractor = DOBSvgExtractor::new(render_result, image_fetcher).unwrap(); + let svg_content = svg_extractor + .extract_svg() + .await + .unwrap() + .unwrap_or_default(); + println!("svg_content: {svg_content}"); +} diff --git a/src/tests/dob1/decoder.rs b/src/tests/dob1/decoder.rs index 84e5b98..6f78220 100644 --- a/src/tests/dob1/decoder.rs +++ b/src/tests/dob1/decoder.rs @@ -2,8 +2,10 @@ use ckb_types::h256; use serde_json::{json, Value}; use crate::{ + client::ImageFetchClient, decoder::DOBDecoder, - tests::prepare_settings, + svg::DOBSvgExtractor, + tests::{prepare_settings, SettingType}, types::{ ClusterDescriptionField, DOBClusterFormat, DOBClusterFormatV0, DOBClusterFormatV1, DOBDecoderFormat, DecoderLocationType, @@ -60,10 +62,27 @@ fn test_print_dob1_ingreidents() { #[tokio::test] async fn test_dob1_basic_decode() { - let settings = prepare_settings("dob/1"); + let settings = prepare_settings(SettingType::Testnet, vec![]); let (content, dob_metadata) = generate_dob1_ingredients(); let decoder = DOBDecoder::new(settings); let dna = content.get("dna").unwrap().as_str().unwrap(); let render_result = decoder.decode_dna(dna, dob_metadata).await.expect("decode"); println!("\nrender_result: {}", render_result); } + +#[tokio::test] +async fn test_mainnet_dob1_decode_to_svg() { + let settings = prepare_settings(SettingType::Mainnet, vec![]); + let image_fetcher = ImageFetchClient::new(&settings.image_fetcher_url, 10); + let (content, dob_metadata) = generate_dob1_ingredients(); + let decoder = DOBDecoder::new(settings); + let dna = content.get("dna").unwrap().as_str().unwrap(); + let render_result = decoder.decode_dna(dna, dob_metadata).await.expect("decode"); + let svg_extractor = DOBSvgExtractor::new(render_result, image_fetcher).unwrap(); + let svg_content = svg_extractor + .extract_svg() + .await + .unwrap() + .unwrap_or_default(); + println!("svg_content: {svg_content}"); +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 0616f35..c8572c7 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -1,64 +1,30 @@ -use ckb_types::h256; +use std::fs; -use crate::types::{HashType, OnchainDecoderDeployment, ScriptId, Settings}; +use crate::types::Settings; mod dob0; mod dob1; -fn prepare_settings(version: &str) -> Settings { - Settings { - ckb_rpc: "https://testnet.ckbapp.dev/".to_string(), - protocol_versions: vec![version.to_string()], - decoders_cache_directory: "cache/decoders".parse().unwrap(), - dobs_cache_directory: "cache/dobs".parse().unwrap(), - available_spores: vec![ - ScriptId { - code_hash: h256!( - "0x685a60219309029d01310311dba953d67029170ca4848a4ff638e57002130a0d" - ), - hash_type: HashType::Data1, - }, - ScriptId { - code_hash: h256!( - "0x5e063b4c0e7abeaa6a428df3b693521a3050934cf3b0ae97a800d1bc31449398" - ), - hash_type: HashType::Data1, - }, - ], - available_clusters: vec![ - ScriptId { - code_hash: h256!( - "0x0bbe768b519d8ea7b96d58f1182eb7e6ef96c541fbd9526975077ee09f049058" - ), - hash_type: HashType::Data1, - }, - ScriptId { - code_hash: h256!( - "0x7366a61534fa7c7e6225ecc0d828ea3b5366adec2b58206f2ee84995fe030075" - ), - hash_type: HashType::Data1, - }, - ], - onchain_decoder_deployment: vec![ - OnchainDecoderDeployment { - code_hash: h256!( - "0xb82abd59ade361a014f0abb692f71b0feb880693c3ccb95b9137b73551d872ce" - ), - tx_hash: h256!( - "0xb2497dc3e616055125ef8276be7ee21986d2cd4b2ce90992725386cabcb6ea7f" - ), - out_index: 0, - }, - OnchainDecoderDeployment { - code_hash: h256!( - "0x32f29aba4b17f3d05bec8cec55d50ef86766fd0bf82fdedaa14269f344d3784a" - ), - tx_hash: h256!( - "0x987cf95d129a2dcc2cdf7bd387c1bd888fa407e3c5a3d511fd80c80dcf6c6b67" - ), - out_index: 0, - }, - ], - ..Default::default() - } +pub enum SettingType { + Mainnet, + Testnet, +} + +const TESTNET_SETTINGS_FILE: &str = "./settings.toml"; +const MAINNET_SETTINGS_FILE: &str = "./settings.mainnet.toml"; + +fn prepare_settings(setting_type: SettingType, extra_version: Vec<&str>) -> Settings { + let settings_file = match setting_type { + SettingType::Mainnet => { + fs::read_to_string(MAINNET_SETTINGS_FILE).expect("read settings.mainnet.toml") + } + SettingType::Testnet => { + fs::read_to_string(TESTNET_SETTINGS_FILE).expect("read settings.toml") + } + }; + let mut settings: Settings = toml::from_str(&settings_file).unwrap(); + settings + .protocol_versions + .extend(extra_version.iter().map(|v| v.to_string())); + settings } From f4baebc1bed051403a21dfa0af1349f674242a51 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Fri, 8 Aug 2025 19:51:03 +0800 Subject: [PATCH 04/26] chore: update extractor --- src/server.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/server.rs b/src/server.rs index 2f5ca16..f001cc1 100644 --- a/src/server.rs +++ b/src/server.rs @@ -2,6 +2,7 @@ use std::fs; use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use base64::{engine::general_purpose::STANDARD, Engine}; use jsonrpsee::core::async_trait; use jsonrpsee::{proc_macros::rpc, tracing, types::error::ErrorObjectOwned}; use serde::{Deserialize, Serialize}; @@ -43,6 +44,13 @@ trait DecoderRpc { #[method(name = "dob_decode_svg")] async fn decode_svg(&self, hexed_spore_id: String) -> Result; + + #[method(name = "dob_extract_image_from_fsuri")] + async fn extract_image_from_fsuri( + &self, + fsuri: String, + encode_type: Option, + ) -> Result; } pub struct DecoderStandaloneServer { @@ -153,6 +161,30 @@ impl DecoderRpcServer for DecoderStandaloneServer { .await?; Ok(svg.unwrap_or(Default::default())) } + + async fn extract_image_from_fsuri( + &self, + fsuri: String, + encode_type: Option, + ) -> Result { + let mut image_fetcher = + ImageFetchClient::new(&self.decoder.setting().image_fetcher_url, 10); + let raw_images = image_fetcher.fetch_images(&[fsuri]).await?; + let image = raw_images.first().unwrap(); + + match encode_type.as_deref() { + Some("hex") => Ok(hex::encode(image)), + Some("base64") => Ok(STANDARD.encode(image)), + unknown => Err(ErrorObjectOwned::owned::( + -1, + format!( + "Unknown encode type: {}. Supported types: 'base64', 'hex'", + unknown.unwrap_or("unknown") + ), + None, + )), + } + } } fn trim_0x(hexed: &str) -> &str { From 446b12696defcbe969d56d3280304e6e61366d31 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Wed, 20 Aug 2025 22:33:27 +0800 Subject: [PATCH 05/26] feat: basic text render functionality prepared --- Cargo.lock | 127 ++++- Cargo.toml | 1 + README.md | 46 +- src/{svg.rs => svg/mod.rs} | 2 + src/svg/puretext/constants/key.rs | 29 ++ src/svg/puretext/constants/mod.rs | 2 + src/svg/puretext/constants/regex.rs | 26 + src/svg/puretext/mod.rs | 36 ++ src/svg/puretext/parsers/background_parser.rs | 39 ++ src/svg/puretext/parsers/mod.rs | 4 + src/svg/puretext/parsers/style_parser.rs | 122 +++++ src/svg/puretext/parsers/text_parser.rs | 205 ++++++++ src/svg/puretext/parsers/traits_parser.rs | 154 ++++++ src/svg/puretext/render.rs | 452 ++++++++++++++++++ src/tests/dob0/legacy_decoder.rs | 118 ++++- 15 files changed, 1359 insertions(+), 4 deletions(-) rename src/{svg.rs => svg/mod.rs} (99%) create mode 100644 src/svg/puretext/constants/key.rs create mode 100644 src/svg/puretext/constants/mod.rs create mode 100644 src/svg/puretext/constants/regex.rs create mode 100644 src/svg/puretext/mod.rs create mode 100644 src/svg/puretext/parsers/background_parser.rs create mode 100644 src/svg/puretext/parsers/mod.rs create mode 100644 src/svg/puretext/parsers/style_parser.rs create mode 100644 src/svg/puretext/parsers/text_parser.rs create mode 100644 src/svg/puretext/parsers/traits_parser.rs create mode 100644 src/svg/puretext/render.rs diff --git a/Cargo.lock b/Cargo.lock index 8eebda7..e1c6885 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.81" @@ -226,6 +241,21 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "ckb-chain-spec" version = "0.116.1" @@ -763,6 +793,7 @@ name = "dob-decoder-server" version = "0.1.0" dependencies = [ "base64 0.22.1", + "chrono", "ckb-hash", "ckb-jsonrpc-types", "ckb-sdk", @@ -1290,6 +1321,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.61.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "idna" version = "0.5.0" @@ -1649,6 +1704,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.16.0" @@ -3087,7 +3151,7 @@ version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" dependencies = [ - "windows-core", + "windows-core 0.54.0", "windows-targets 0.52.4", ] @@ -3097,10 +3161,51 @@ version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" dependencies = [ - "windows-result", + "windows-result 0.1.0", "windows-targets 0.52.4", ] +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result 0.3.4", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.57", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.57", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-result" version = "0.1.0" @@ -3110,6 +3215,24 @@ dependencies = [ "windows-targets 0.52.4", ] +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 80d4d57..66cf41b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ serde = { version = "1.0", features = ["serde_derive"] } futures = "0.3" lazy_static = "1.4" lazy-regex = "3.1.0" +chrono = { version = "0.4", features = ["serde"] } ckb-vm = { version = "0.24", features = ["asm"] } spore-types = { git = "https://github.com/sporeprotocol/spore-contract", rev = "81315ca" } diff --git a/README.md b/README.md index fb2b04a..6c1525c 100644 --- a/README.md +++ b/README.md @@ -40,13 +40,57 @@ $ RUST_LOG=dob_decoder_server=debug cargo run And then, try it out: +**Get protocol versions:** + +```bash +$ curl -H 'content-type: application/json' -d '{ + "id": 1, + "jsonrpc": "2.0", + "method": "dob_protocol_version", + "params": [] +}' http://localhost:8090 +``` + +**Decode a spore ID:** + ```bash $ echo '{ "id": 2, "jsonrpc": "2.0", "method": "dob_decode", "params": [ - "" + "4f7fb83a65dae9b95c21e55d5776a84f17bb6377681befeedb20a077ce1d8aad" + ] +}' \ +| curl -H 'content-type: application/json' -d @- \ +http://localhost:8090 +``` + +**Decode and extract SVG from another example spore ID on mainnet:** + +```bash +$ echo '{ + "id": 3, + "jsonrpc": "2.0", + "method": "dob_decode_svg", + "params": [ + "bbe57f0e7f7ca6e6c59007b28150e39c9c6f5c209493801cfc9ef125e0937ed4" + ] +}' \ +| curl -H 'content-type: application/json' -d @- \ +http://localhost:8090 +``` + +**Extract image from a btcfs or ipfs path:** + +```bash +$ echo '{ + "id": 3, + "jsonrpc": "2.0", + "method": "dob_extract_image_from_fsuri", + "params": [ + "btcfs://5895004e95c8a4b80f05f5314d310067a703134515d82effc2ec6eba0dda3fc9i0", + "base64" ] }' \ | curl -H 'content-type: application/json' -d @- \ diff --git a/src/svg.rs b/src/svg/mod.rs similarity index 99% rename from src/svg.rs rename to src/svg/mod.rs index cc8840e..24edad9 100644 --- a/src/svg.rs +++ b/src/svg/mod.rs @@ -7,6 +7,8 @@ use crate::{ types::{Error, StandardDOBOutput}, }; +pub mod puretext; + const DOB0_TRAIT_NAME: &str = "prev.bg"; const DOB1_TRAIT_NAME: &str = "IMAGE"; const DEFAULT_SIZE: u32 = 500; diff --git a/src/svg/puretext/constants/key.rs b/src/svg/puretext/constants/key.rs new file mode 100644 index 0000000..8b5530f --- /dev/null +++ b/src/svg/puretext/constants/key.rs @@ -0,0 +1,29 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Key { + BgColor, + Prev, + Image, +} + +impl std::fmt::Display for Key { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Key::BgColor => write!(f, "prev.bgcolor"), + Key::Prev => write!(f, "prev"), + Key::Image => write!(f, "IMAGE"), + } + } +} + +impl std::str::FromStr for Key { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "prev.bgcolor" => Ok(Key::BgColor), + "prev" => Ok(Key::Prev), + "IMAGE" => Ok(Key::Image), + _ => Err(format!("Unknown key: {}", s)), + } + } +} diff --git a/src/svg/puretext/constants/mod.rs b/src/svg/puretext/constants/mod.rs new file mode 100644 index 0000000..3630d6a --- /dev/null +++ b/src/svg/puretext/constants/mod.rs @@ -0,0 +1,2 @@ +pub mod key; +pub mod regex; diff --git a/src/svg/puretext/constants/regex.rs b/src/svg/puretext/constants/regex.rs new file mode 100644 index 0000000..432f1b8 --- /dev/null +++ b/src/svg/puretext/constants/regex.rs @@ -0,0 +1,26 @@ +use lazy_regex::regex; +use serde_json::Value; + +pub static ARRAY_REG: lazy_regex::Lazy = + lazy_regex::lazy_regex!(r"\%(.*?)\):(\[.*?\])"); +pub static ARRAY_INDEX_REG: lazy_regex::Lazy = lazy_regex::lazy_regex!(r"(\d+)<_>$"); +pub static GLOBAL_TEMPLATE_REG: lazy_regex::Lazy = + lazy_regex::lazy_regex!(r"^prev<(.*?)>"); +pub static TEMPLATE_REG: lazy_regex::Lazy = lazy_regex::lazy_regex!(r"^(.*?)<(.*?)>"); + +pub fn parse_string_to_array(s: &str) -> Vec { + // This regex matches anything inside single quotes: '...' + let re = regex::Regex::new(r"'([^']*)'").unwrap(); + re.captures_iter(s) + .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string())) + .collect() +} + +pub fn parse_value_to_string(val: &Value) -> String { + match val { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + _ => String::new(), + } +} diff --git a/src/svg/puretext/mod.rs b/src/svg/puretext/mod.rs new file mode 100644 index 0000000..5cd337f --- /dev/null +++ b/src/svg/puretext/mod.rs @@ -0,0 +1,36 @@ +use serde_json::Value; + +pub mod constants; +pub mod parsers; +pub mod render; + +#[derive(Clone, Debug)] +pub struct SimpleDOBOutput { + pub name: String, + pub value: Value, +} + +pub trait TraitExt { + fn get_string_value(&self) -> Option<&str>; + fn get_number_value(&self) -> Option; + fn get_timestamp_value(&self) -> Option; + fn get_svg_value(&self) -> Option<&str>; +} + +impl TraitExt for SimpleDOBOutput { + fn get_string_value(&self) -> Option<&str> { + self.value.as_str() + } + + fn get_number_value(&self) -> Option { + self.value.as_u64() + } + + fn get_timestamp_value(&self) -> Option { + self.value.as_u64() + } + + fn get_svg_value(&self) -> Option<&str> { + self.value.as_str() + } +} diff --git a/src/svg/puretext/parsers/background_parser.rs b/src/svg/puretext/parsers/background_parser.rs new file mode 100644 index 0000000..720ca77 --- /dev/null +++ b/src/svg/puretext/parsers/background_parser.rs @@ -0,0 +1,39 @@ +use crate::svg::puretext::{constants::key::Key, SimpleDOBOutput, TraitExt as _}; + +pub fn get_background_color_by_traits(traits: &[SimpleDOBOutput]) -> Option<&SimpleDOBOutput> { + traits + .iter() + .find(|trait_| trait_.name == Key::BgColor.to_string()) +} + +pub struct BackgroundColorOptions { + pub default_color: Option, +} + +impl Default for BackgroundColorOptions { + fn default() -> Self { + Self { + default_color: Some("#000".to_string()), + } + } +} + +pub fn background_color_parser( + traits: &[SimpleDOBOutput], + options: Option, +) -> String { + let bg_color_trait = get_background_color_by_traits(traits); + + if let Some(trait_) = bg_color_trait { + if let Some(value) = trait_.get_string_value() { + if value.starts_with("#(") && value.ends_with(')') { + return value.replace("#(", "linear-gradient("); + } + return value.to_string(); + } + } + + options + .and_then(|opt| opt.default_color) + .unwrap_or_else(|| "#000".to_string()) +} diff --git a/src/svg/puretext/parsers/mod.rs b/src/svg/puretext/parsers/mod.rs new file mode 100644 index 0000000..32881ad --- /dev/null +++ b/src/svg/puretext/parsers/mod.rs @@ -0,0 +1,4 @@ +pub mod background_parser; +pub mod style_parser; +pub mod text_parser; +pub mod traits_parser; diff --git a/src/svg/puretext/parsers/style_parser.rs b/src/svg/puretext/parsers/style_parser.rs new file mode 100644 index 0000000..4310451 --- /dev/null +++ b/src/svg/puretext/parsers/style_parser.rs @@ -0,0 +1,122 @@ +use lazy_regex::regex; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ParsedStyleFormat { + Bold, + Italic, + Strikethrough, + Underline, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParsedStyleAlignment { + Left, + Center, + Right, +} + +#[derive(Debug, Clone)] +pub struct ParsedStyle { + pub color: String, + pub format: Vec, + pub alignment: ParsedStyleAlignment, + pub break_line: u32, +} + +impl Default for ParsedStyle { + fn default() -> Self { + Self { + color: "#fff".to_string(), + format: Vec::new(), + alignment: ParsedStyleAlignment::Left, + break_line: 1, + } + } +} + +pub struct StyleParserOptions { + pub base_style: Option, +} + +impl Default for StyleParserOptions { + fn default() -> Self { + Self { base_style: None } + } +} + +pub fn style_parser(input: &str, options: Option) -> ParsedStyle { + let mut text = input.to_string(); + let mut result = options.and_then(|opt| opt.base_style).unwrap_or_default(); + + // Remove angle brackets if present + if text.starts_with('<') && text.ends_with('>') { + text = text[1..text.len() - 1].to_string(); + } + + // Parse 6-digit hex color + if let Some(captures) = regex!(r"#([0-9a-fA-F]{6})").captures(&text) { + if let Some(color_match) = captures.get(1) { + result.color = format!("#{}", color_match.as_str()); + text = regex!(r"#([0-9a-fA-F]{6})").replace(&text, "").to_string(); + } + } + + // Parse 3-digit hex color + if let Some(captures) = regex!(r"#([0-9a-fA-F]{3})").captures(&text) { + if let Some(color_match) = captures.get(1) { + result.color = format!("#{}", color_match.as_str()); + text = regex!(r"#([0-9a-fA-F]{3})").replace(&text, "").to_string(); + } + } + + // Parse format specifiers (*bisu) + if let Some(captures) = regex!(r"\*([bisu]+)").captures(&text) { + if let Some(format_match) = captures.get(1) { + let format_str = format_match.as_str(); + let mut formats = Vec::new(); + + for ch in format_str.chars() { + let format = match ch { + 'b' => Some(ParsedStyleFormat::Bold), + 'i' => Some(ParsedStyleFormat::Italic), + 's' => Some(ParsedStyleFormat::Strikethrough), + 'u' => Some(ParsedStyleFormat::Underline), + _ => None, + }; + if let Some(fmt) = format { + formats.push(fmt); + } + } + + result.format = formats; + text = regex!(r"\*([bisu]+)").replace(&text, "").to_string(); + } + } + + // Parse alignment (@l, @c, @r) + if let Some(captures) = regex!(r"@(l|c|r)").captures(&text) { + if let Some(align_match) = captures.get(1) { + result.alignment = match align_match.as_str() { + "l" => ParsedStyleAlignment::Left, + "c" => ParsedStyleAlignment::Center, + "r" => ParsedStyleAlignment::Right, + _ => ParsedStyleAlignment::Left, + }; + text = regex!(r"@(l|c|r)").replace(&text, "").to_string(); + } + } + + // Parse no line break (&) + if regex!(r"&").is_match(&text) { + result.break_line = 0; + text = regex!(r"&").replace(&text, "").to_string(); + } + + // Parse line breaks (~) + let tilde_count = text.chars().filter(|&c| c == '~').count(); + if tilde_count > 0 { + result.break_line = tilde_count as u32 + 1; + } + + result +} diff --git a/src/svg/puretext/parsers/text_parser.rs b/src/svg/puretext/parsers/text_parser.rs new file mode 100644 index 0000000..0c233cd --- /dev/null +++ b/src/svg/puretext/parsers/text_parser.rs @@ -0,0 +1,205 @@ +use std::collections::HashMap; + +use serde_json::Value; + +use crate::svg::puretext::{ + constants::{key::Key, regex::*}, + parsers::{ + background_parser::{background_color_parser, BackgroundColorOptions}, + style_parser::{ + style_parser, ParsedStyle, ParsedStyleAlignment, ParsedStyleFormat, StyleParserOptions, + }, + }, + SimpleDOBOutput, TraitExt as _, +}; + +pub const DEFAULT_TEMPLATE: &str = "%k: %v"; + +#[derive(Debug, Clone)] +pub struct TextParserOptions { + pub default_template: Option, +} + +impl Default for TextParserOptions { + fn default() -> Self { + Self { + default_template: None, + } + } +} + +#[derive(Debug, Clone)] +pub struct StyleCss { + pub text_align: Option, + pub color: Option, + pub font_weight: Option, + pub font_style: Option, + pub text_decoration: Option, +} + +impl Default for StyleCss { + fn default() -> Self { + Self { + text_align: None, + color: None, + font_weight: None, + font_style: None, + text_decoration: None, + } + } +} + +#[derive(Debug, Clone)] +pub struct TextItem { + pub name: String, + pub value: Value, + pub parsed_style: ParsedStyle, + pub template: String, + pub text: String, + pub style: StyleCss, +} + +#[derive(Debug, Clone)] +pub struct TextParserResult { + pub items: Vec, + pub bg_color: String, +} + +pub fn render_text_params_parser( + traits: &[SimpleDOBOutput], + index_var_register: &HashMap, + options: Option, +) -> TextParserResult { + let bg_color = background_color_parser( + traits, + Some(BackgroundColorOptions { + default_color: Some("#000".to_string()), + }), + ); + + let mut template = options + .as_ref() + .and_then(|opt| opt.default_template.clone()) + .unwrap_or_else(|| DEFAULT_TEMPLATE.to_string()); + + let mut style = style_parser("", None); + + // Find global template trait + let global_template_trait = traits + .iter() + .find(|trait_| GLOBAL_TEMPLATE_REG.is_match(&trait_.name)); + + if let Some(global_trait) = global_template_trait { + if let Some(value) = global_trait.get_string_value() { + let mut processed_value = value.to_string(); + if !processed_value.starts_with('<') && !processed_value.ends_with('>') { + processed_value = format!("<{}>", processed_value); + } + style = style_parser(&processed_value, None); + } + + // Extract template from trait name + if let Some(captures) = TEMPLATE_REG.captures(&global_trait.name) { + if let Some(template_match) = captures.get(2) { + template = template_match.as_str().to_string(); + } + } + } + + let items: Vec = traits + .iter() + .filter(|trait_| { + !trait_.name.starts_with(&Key::Prev.to_string()) + && !index_var_register.contains_key(&trait_.name) + && trait_.name != Key::Image.to_string() + }) + .map(|trait_| { + let mut current_template = template.clone(); + let mut parsed_style = style.clone(); + let mut processed_value = trait_.value.clone(); + let name = trait_.name.clone(); + + // Parse value for layout and style + if let Some(value_str) = trait_.get_string_value() { + if let Some(captures) = TEMPLATE_REG.captures(&value_str) { + if let Some(value_match) = captures.get(1) { + processed_value = Value::String(value_match.as_str().to_string()); + } + if let Some(style_match) = captures.get(2) { + let style_str = format!("<{}>", style_match.as_str()); + parsed_style = style_parser( + &style_str, + Some(StyleParserOptions { + base_style: Some(parsed_style.clone()), + }), + ); + } + } + } + + // Parse name for template + let mut processed_name = name.clone(); + if let Some(captures) = TEMPLATE_REG.captures(&name) { + if let Some(name_match) = captures.get(1) { + processed_name = name_match.as_str().to_string(); + } + if let Some(template_match) = captures.get(2) { + current_template = template_match.as_str().to_string(); + } + } + + // Generate text from template + let text = current_template + .replace("%k", &processed_name) + .replace("%v", &parse_value_to_string(&processed_value)) + .replace("%%", "%"); + + // Generate CSS style + let mut style_css = StyleCss::default(); + + match parsed_style.alignment { + ParsedStyleAlignment::Left => { + style_css.text_align = Some("left".to_string()); + } + ParsedStyleAlignment::Center => { + style_css.text_align = Some("center".to_string()); + } + ParsedStyleAlignment::Right => { + style_css.text_align = Some("right".to_string()); + } + } + + if !parsed_style.color.is_empty() { + style_css.color = Some(parsed_style.color.clone()); + } + + for format in &parsed_style.format { + match format { + ParsedStyleFormat::Bold => { + style_css.font_weight = Some("700".to_string()); + } + ParsedStyleFormat::Italic => { + style_css.font_style = Some("italic".to_string()); + } + ParsedStyleFormat::Underline => { + style_css.text_decoration = Some("underline".to_string()); + } + ParsedStyleFormat::Strikethrough => { + style_css.text_decoration = Some("line-through".to_string()); + } + } + } + + TextItem { + name: processed_name, + value: processed_value, + parsed_style, + template: current_template, + text, + style: style_css, + } + }) + .collect(); + + TextParserResult { items, bg_color } +} diff --git a/src/svg/puretext/parsers/traits_parser.rs b/src/svg/puretext/parsers/traits_parser.rs new file mode 100644 index 0000000..f925a0a --- /dev/null +++ b/src/svg/puretext/parsers/traits_parser.rs @@ -0,0 +1,154 @@ +use serde_json::Value; +use std::collections::HashMap; + +use crate::{ + svg::puretext::{ + constants::regex::{parse_string_to_array, ARRAY_INDEX_REG, ARRAY_REG}, + SimpleDOBOutput, TraitExt, + }, + types::{ParsedTrait, StandardDOBOutput}, +}; + +#[derive(Clone, Debug)] +pub struct TraitsParserResult { + pub traits: Vec, + pub index_var_register: HashMap, +} + +pub fn dob_output_parser(items: &[StandardDOBOutput]) -> TraitsParserResult { + // Build index variable register + let index_var_register = items + .iter() + .filter_map(|item| { + let first_trait = item.traits.first()?; + let string_value = first_trait.get_string_value()?; + + if let Some(captures) = ARRAY_INDEX_REG.captures(string_value) { + if let Some(index_match) = captures.get(1) { + if let Ok(int_index) = index_match.as_str().parse::() { + return Some((item.name.clone(), int_index)); + } + } + } + None + }) + .collect::>(); + + // Parse traits + let traits = items + .iter() + .filter_map(|item| { + let first_trait = item.traits.first()?; + + // Handle String traits + if let Some(string_value) = first_trait.get_string_value() { + let mut value = string_value.to_string(); + + // Handle array indexing + if let Some(captures) = ARRAY_REG.captures(&value) { + if let Some(var_name_match) = captures.get(1) { + if let Some(array_match) = captures.get(2) { + let var_name = var_name_match.as_str(); + let array = parse_string_to_array(array_match.as_str()); + + if let Some(&index) = index_var_register.get(var_name) { + let array_index = (index as usize) % array.len(); + value = array[array_index].clone(); + } + } + } + } + + return Some(SimpleDOBOutput { + name: item.name.clone(), + value: Value::String(value), + }); + } + + // Handle Number traits + if let Some(number_value) = first_trait.get_number_value() { + return Some(SimpleDOBOutput { + name: item.name.clone(), + value: Value::Number(serde_json::Number::from(number_value)), + }); + } + + // Handle Timestamp traits + if let Some(timestamp_value) = first_trait.get_timestamp_value() { + let mut timestamp = timestamp_value; + + // Convert 10-digit timestamp to milliseconds if needed + if timestamp.to_string().len() == 10 { + timestamp *= 1000; + } + + // Convert to ISO string format + let timestamp_ms = timestamp as i64; + let datetime = chrono::DateTime::from_timestamp_millis(timestamp_ms) + .unwrap_or_else(|| chrono::Utc::now()); + let iso_string = datetime.to_rfc3339(); + + return Some(SimpleDOBOutput { + name: item.name.clone(), + value: Value::String(iso_string), + }); + } + + // Handle SVG traits + if let Some(svg_value) = first_trait.get_svg_value() { + let resolved_svg = resolve_svg_traits(svg_value); + return Some(SimpleDOBOutput { + name: item.name.clone(), + value: Value::String(resolved_svg), + }); + } + + None + }) + .collect(); + + TraitsParserResult { + traits, + index_var_register, + } +} + +fn resolve_svg_traits(svg: &str) -> String { + // For now, just return the SVG as-is + // This could be expanded to handle SVG trait resolution + svg.to_string() +} + +impl TraitExt for ParsedTrait { + fn get_string_value(&self) -> Option<&str> { + if self.type_ == "String" { + self.value.as_str() + } else { + None + } + } + + fn get_number_value(&self) -> Option { + if self.type_ == "Number" { + self.value.as_u64() + } else { + None + } + } + + fn get_timestamp_value(&self) -> Option { + if self.type_ == "Timestamp" { + self.value.as_u64() + } else { + None + } + } + + fn get_svg_value(&self) -> Option<&str> { + if self.type_ == "SVG" { + self.value.as_str() + } else { + None + } + } +} diff --git a/src/svg/puretext/render.rs b/src/svg/puretext/render.rs new file mode 100644 index 0000000..9914382 --- /dev/null +++ b/src/svg/puretext/render.rs @@ -0,0 +1,452 @@ +use crate::svg::puretext::parsers::style_parser::ParsedStyleAlignment; +use crate::svg::puretext::parsers::text_parser::{TextItem, TextParserResult}; + +#[derive(Debug, Clone)] +pub struct RenderProps { + pub items: Vec, + pub bg_color: String, +} + +impl From for RenderProps { + fn from(result: TextParserResult) -> Self { + Self { + items: result.items, + bg_color: result.bg_color, + } + } +} + +#[derive(Debug, Clone)] +pub struct RenderElement { + pub key: String, + pub element_type: String, + pub props: ElementProps, +} + +#[derive(Debug, Clone)] +pub struct ElementProps { + pub children: Vec, + pub style: StyleProps, +} + +#[derive(Debug, Clone)] +pub struct StyleProps { + pub display: Option, + pub justify_content: Option, + pub flex_wrap: Option, + pub width: Option, + pub margin: Option, + pub height: Option, + pub text_align: Option, + pub color: Option, + pub font_weight: Option, + pub font_style: Option, + pub text_decoration: Option, +} + +impl Default for StyleProps { + fn default() -> Self { + Self { + display: None, + justify_content: None, + flex_wrap: None, + width: None, + margin: None, + height: None, + text_align: None, + color: None, + font_weight: None, + font_style: None, + text_decoration: None, + } + } +} + +pub fn render_text_svg(props: RenderProps) -> String { + // Convert text items to render elements + let children = convert_items_to_elements(&props.items); + + // Generate SVG + generate_svg(&children, &props.bg_color) +} + +fn convert_items_to_elements(items: &[TextItem]) -> Vec { + let mut elements = Vec::new(); + + for item in items { + let justify_content = match item.parsed_style.alignment { + ParsedStyleAlignment::Left => "flex-start", + ParsedStyleAlignment::Center => "center", + ParsedStyleAlignment::Right => "flex-end", + }; + + let mut style = StyleProps::default(); + style.display = Some("flex".to_string()); + style.justify_content = Some(justify_content.to_string()); + style.flex_wrap = Some("wrap".to_string()); + style.width = Some("100%".to_string()); + style.margin = Some("0".to_string()); + + // Apply text styling + if let Some(color) = &item.style.color { + style.color = Some(color.clone()); + } + if let Some(font_weight) = &item.style.font_weight { + style.font_weight = Some(font_weight.clone()); + } + if let Some(font_style) = &item.style.font_style { + style.font_style = Some(font_style.clone()); + } + if let Some(text_decoration) = &item.style.text_decoration { + style.text_decoration = Some(text_decoration.clone()); + } + + let element = RenderElement { + key: item.name.clone(), + element_type: "p".to_string(), + props: ElementProps { + children: vec![item.text.clone()], + style: style.clone(), + }, + }; + + // Handle break line logic + if item.parsed_style.break_line == 0 { + // No line break - just add the element + elements.push(element); + } else { + // Add the main element + elements.push(element); + + // Add additional line breaks + for i in 0..item.parsed_style.break_line { + let break_element = RenderElement { + key: format!("{}-break-{}", item.name, i), + element_type: "p".to_string(), + props: ElementProps { + children: vec![], + style: StyleProps { + height: Some("36px".to_string()), + margin: Some("0".to_string()), + ..Default::default() + }, + }, + }; + elements.push(break_element); + } + } + } + + elements +} + +fn generate_svg(elements: &[RenderElement], bg_color: &str) -> String { + let width = 500; + let padding_x = 20; + let padding_y = 30; + let line_height = 27; + let font_size = 36; + + let mut svg_content = String::new(); + let mut current_y = padding_y + line_height; // Start after top padding + let mut used_font_weights = std::collections::HashSet::new(); + + // Calculate dynamic height based on content with minimum of 500 + let calculated_height = elements.len() * line_height + padding_y; + let height = std::cmp::max(calculated_height as u32, 500); + + // First pass: collect used font weights + for element in elements { + if element.element_type == "p" { + let font_weight_to_use = if let Some(font_weight) = &element.props.style.font_weight { + if font_weight == "bold" { + "700" + } else { + "400" + } + } else { + "400" + }; + used_font_weights.insert(font_weight_to_use.to_string()); + } + } + + // Second pass: generate SVG content + for element in elements { + if element.element_type == "p" { + if !element.props.children.is_empty() { + let text = &element.props.children[0]; + if !text.is_empty() { + let text_anchor = match element.props.style.justify_content.as_deref() { + Some("center") => "middle", + Some("flex-end") => "end", + _ => "start", + }; + + let color = element.props.style.color.as_deref().unwrap_or("#ffffff"); + + // Determine font weight for Turret Road font family + let font_weight_to_use = + if let Some(font_weight) = &element.props.style.font_weight { + font_weight + } else { + "400" + }; + + let text_element = format!( + r#"{}"#, + padding_x, + current_y, + font_weight_to_use, + font_size, + color, + text_anchor, + escape_xml(text) + ); + + svg_content.push_str(&text_element); + svg_content.push('\n'); + } + } + current_y += line_height; + } + } + + // Create font definitions with only used weights + let font_defs = create_font_definitions(&used_font_weights); + + // Create gradient definition if bg_color is a gradient + let (background_rect, gradient_defs) = if bg_color.starts_with("linear-gradient") { + create_gradient_background(bg_color, width, height) + } else { + // Use solid color + let rect = format!( + r#""#, + width, height, bg_color + ); + (rect, String::new()) + }; + + // Create the complete SVG + format!( + r#"{}{}{}{}"#, + width, height, font_defs, gradient_defs, background_rect, svg_content + ) +} + +fn create_gradient_background(gradient_css: &str, width: u32, height: u32) -> (String, String) { + // Parse CSS linear-gradient format: linear-gradient(70deg, blue, pink, #f00) + let gradient_id = "background-gradient"; + + // Extract angle and colors from the gradient string + let (angle, colors) = parse_gradient_css(gradient_css); + + // Calculate gradient coordinates based on angle + let (x1, y1, x2, y2) = calculate_gradient_coordinates(angle); + + // Create gradient stops + let mut stops = String::new(); + let num_colors = colors.len(); + for (i, color) in colors.iter().enumerate() { + let offset = if num_colors == 1 { + "0%".to_string() + } else { + format!("{}%", (i * 100) / (num_colors - 1)) + }; + stops.push_str(&format!( + r#""#, + offset, color + )); + } + + // Create gradient definition + let gradient_defs = format!( + r#"{}"#, + gradient_id, x1, y1, x2, y2, stops + ); + + // Create background rect with gradient fill + let background_rect = format!( + r#""#, + width, height, gradient_id + ); + + (background_rect, gradient_defs) +} + +fn create_font_definitions(_used_font_weights: &std::collections::HashSet) -> String { + // Include the complete Google Fonts CSS for Turret Road font + // This is the actual CSS fetched from: https://fonts.googleapis.com/css2?family=Turret+Road:wght@200;300;400;500;700;800&display=swap + let mut font_defs = String::from(r#""#, + ); + + font_defs +} + +fn calculate_gradient_coordinates(angle: f64) -> (String, String, String, String) { + // For CSS linear-gradient, 0deg points to the right, 90deg points down + // We need to calculate the gradient line that goes through the rectangle at the given angle + + // Normalize angle to 0-360 range + let normalized_angle = angle % 360.0; + + // Convert angle to radians + let angle_rad = normalized_angle.to_radians(); + + // Calculate the direction vector + let dx = angle_rad.sin(); + let dy = angle_rad.cos(); + + // For a rectangle with width=100% and height=100%, we need to find + // the intersection points of the gradient line with the rectangle boundaries + // The gradient line passes through the center (50%, 50%) of the rectangle + + // Calculate the intersection points with the rectangle boundaries + // We'll use parametric line equations to find where the line intersects the rectangle + + let center_x = 0.5; // 50% + let center_y = 0.5; // 50% + + // Find the parameter t where the line intersects each boundary + let mut t_values = Vec::new(); + + // Intersection with left boundary (x = 0) + if dx.abs() > 1e-10 { + let t = -center_x / dx; + let y = center_y + t * dy; + if y >= 0.0 && y <= 1.0 { + t_values.push((t, 0.0, y)); + } + } + + // Intersection with right boundary (x = 1) + if dx.abs() > 1e-10 { + let t = (1.0 - center_x) / dx; + let y = center_y + t * dy; + if y >= 0.0 && y <= 1.0 { + t_values.push((t, 1.0, y)); + } + } + + // Intersection with top boundary (y = 0) + if dy.abs() > 1e-10 { + let t = -center_y / dy; + let x = center_x + t * dx; + if x >= 0.0 && x <= 1.0 { + t_values.push((t, x, 0.0)); + } + } + + // Intersection with bottom boundary (y = 1) + if dy.abs() > 1e-10 { + let t = (1.0 - center_y) / dy; + let x = center_x + t * dx; + if x >= 0.0 && x <= 1.0 { + t_values.push((t, x, 1.0)); + } + } + + // Sort by parameter t to get the two endpoints + t_values.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + + if t_values.len() >= 2 { + let (_, x1, y1) = t_values[0]; + let (_, x2, y2) = t_values[t_values.len() - 1]; + + // Convert to percentage strings + // For CSS linear-gradient, the gradient flows in the direction of the angle + // So we want the start point to be where the gradient begins + ( + format!("{:.1}%", x1 * 100.0), + format!("{:.1}%", y1 * 100.0), + format!("{:.1}%", x2 * 100.0), + format!("{:.1}%", y2 * 100.0), + ) + } else { + // Fallback for edge cases + ( + "0%".to_string(), + "0%".to_string(), + "100%".to_string(), + "100%".to_string(), + ) + } +} + +fn parse_gradient_css(gradient_css: &str) -> (f64, Vec) { + // Parse: linear-gradient(70deg, blue, pink, #f00) + let mut parts = gradient_css + .trim_start_matches("linear-gradient(") + .trim_end_matches(")") + .split(','); + + // Extract angle + let angle_part = parts.next().unwrap_or("0deg").trim(); + let angle = angle_part + .trim_end_matches("deg") + .parse::() + .unwrap_or(0.0); + + // Extract colors + let colors: Vec = parts.map(|s| s.trim().to_string()).collect(); + + (angle, colors) +} + +fn escape_xml(text: &str) -> String { + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") +} + +// Legacy function for backward compatibility +pub fn render_text_params(render_output: Vec) -> String { + use crate::svg::puretext::parsers::{text_parser, traits_parser}; + + let traits_parser_result = traits_parser::dob_output_parser(&render_output); + let text_parser_result = text_parser::render_text_params_parser( + &traits_parser_result.traits, + &traits_parser_result.index_var_register, + None, + ); + + render_text_parser_result_to_svg(&text_parser_result) +} + +pub fn render_text_parser_result_to_svg(text_parser_result: &TextParserResult) -> String { + let props = RenderProps::from(text_parser_result.clone()); + render_text_svg(props) +} diff --git a/src/tests/dob0/legacy_decoder.rs b/src/tests/dob0/legacy_decoder.rs index aba892c..47a6bbd 100644 --- a/src/tests/dob0/legacy_decoder.rs +++ b/src/tests/dob0/legacy_decoder.rs @@ -2,11 +2,15 @@ use ckb_types::{h256, H256}; use crate::client::ImageFetchClient; use crate::decoder::{helpers::decode_spore_content, DOBDecoder}; +use crate::svg::puretext::parsers::{ + text_parser::render_text_params_parser, traits_parser::dob_output_parser, +}; +use crate::svg::puretext::render::render_text_parser_result_to_svg; use crate::svg::DOBSvgExtractor; use crate::tests::{prepare_settings, SettingType}; use crate::types::{ ClusterDescriptionField, DOBClusterFormat, DOBClusterFormatV0, DOBDecoderFormat, - DecoderLocationType, + DecoderLocationType, StandardDOBOutput, }; use serde_json::{json, Value}; @@ -174,6 +178,25 @@ fn test_decode_multiple_spore_data() { }); } +#[tokio::test] +async fn test_unicorn_dna_to_svg() { + let render_result = decode_unicorn_dna(false).await; + let parsed_render_result: Vec = + serde_json::from_str(&render_result).unwrap(); + let output_parser_result = dob_output_parser(&parsed_render_result); + println!("\noutput_parser_result: {output_parser_result:?}"); + + let text_parser_result = render_text_params_parser( + &output_parser_result.traits, + &output_parser_result.index_var_register, + None, + ); + println!("\ntext_parser_result: {text_parser_result:?}"); + + let svg = render_text_parser_result_to_svg(&text_parser_result); + println!("\nGenerated SVG:\n{}", svg); +} + #[tokio::test] async fn test_fetch_and_decode_mainnet_nervape_dna_to_svg() { let settings = prepare_settings(SettingType::Mainnet, vec![]); @@ -196,3 +219,96 @@ async fn test_fetch_and_decode_mainnet_nervape_dna_to_svg() { .unwrap_or_default(); println!("svg_content: {svg_content}"); } + +#[test] +fn test_manual_render_output_to_svg() { + let render_output_source = serde_json::json!([ + { + "name": "wuxing_yinyang", + "traits": [ + { "String": "3<_>" } + ] + }, + { + "name": "prev.bgcolor", + "traits": [ + { "String": "#(70deg, blue, pink, #f00)" } + ] + }, + { + "name": "prev<%v>", + "traits": [ + { "String": "(%wuxing_yinyang):['#FFFFFF', '#FFFFFF', '#FFFFFF', '#FFFFFF', '#FFFFFF', '#000000', '#000000', '#000000', '#000000', '#000000'])" } + ] + }, + { + "name": "Spirits", + "traits": [ + { "String": "(%wuxing_yinyang):['Metal, Golden Body', 'Wood, Blue Body', 'Water, White Body', 'Fire, Red Body', 'Earth, Colorful Body']" } + ] + }, + { + "name": "Yin Yang", + "traits": [ + { "String": "(%wuxing_yinyang):['Yin, Long hair', 'Yin, Long hair', 'Yin, Long hair', 'Yin, Long hair', 'Yin, Long hair', 'Yang, Short Hair', 'Yang, Short Hair', 'Yang, Short Hair', 'Yang, Short Hair', 'Yang, Short Hair']" } + ] + }, + { + "name": "Talents", + "traits": [ + { "String": "(%wuxing_yinyang):['Guard', 'Attack', 'Death', 'Revival', 'Forget', 'Summon', 'Prophet', 'Curse', 'Hermit', 'Crown']" } + ] + }, + { + "name": "Horn", + "traits": [ + { "String": "(%wuxing_yinyang):['Praetorian Horn', 'Warrior Horn', 'Hel Horn', 'Shaman Horn', 'Lethe Horn', 'Bard Horn', 'Sibyl Horn ', 'Necromancer Horn', 'Lao Tsu Horn', 'Caesar Horn']" } + ] + }, + { + "name": "Wings", + "traits": [ + { "String": "Golden Wings" } + ] + }, + { + "name": "Tails<%k: %v>", + "traits": [ + { "String": "Meteor Tails<#000*bi>" } + ] + }, + { + "name": "Horseshoes", + "traits": [ + { "String": "Dimond Horseshoes" } + ] + }, + { + "name": "Destiny Number", + "traits": [ + { "Number": 59616 } + ] + }, + { + "name": "Lucky Number", + "traits": [ + { "Number": 35 } + ] + } + ]); + let render_output: Vec = + serde_json::from_value(render_output_source).unwrap(); + + let output_parser_result = dob_output_parser(&render_output); + println!("\noutput_parser_result: {output_parser_result:?}"); + + let text_parser_result = render_text_params_parser( + &output_parser_result.traits, + &output_parser_result.index_var_register, + None, + ); + println!("\ntext_parser_result: {text_parser_result:?}"); + + let svg = render_text_parser_result_to_svg(&text_parser_result); + println!("\nGenerated SVG:\n{}", svg); +} From a9df8510cd55fa178cb7bae661e2964794e5742a Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Thu, 21 Aug 2025 15:29:28 +0800 Subject: [PATCH 06/26] chore: fix clippy --- src/server.rs | 2 +- src/svg/mod.rs | 29 ++++++-- src/svg/puretext/parsers/mod.rs | 13 ++-- src/svg/puretext/parsers/style_parser.rs | 7 +- src/svg/puretext/parsers/text_parser.rs | 38 ++-------- src/svg/puretext/parsers/traits_parser.rs | 2 +- src/svg/puretext/render.rs | 88 ++++++----------------- src/tests/dob0/legacy_decoder.rs | 30 +++++--- src/tests/dob1/decoder.rs | 6 +- 9 files changed, 83 insertions(+), 132 deletions(-) diff --git a/src/server.rs b/src/server.rs index f001cc1..7c557f2 100644 --- a/src/server.rs +++ b/src/server.rs @@ -159,7 +159,7 @@ impl DecoderRpcServer for DecoderStandaloneServer { let svg = DOBSvgExtractor::new(render_output, image_fetcher)? .extract_svg() .await?; - Ok(svg.unwrap_or(Default::default())) + Ok(svg) } async fn extract_image_from_fsuri( diff --git a/src/svg/mod.rs b/src/svg/mod.rs index 24edad9..caeef37 100644 --- a/src/svg/mod.rs +++ b/src/svg/mod.rs @@ -4,6 +4,10 @@ use serde_json::Value; use crate::{ client::ImageFetchClient, + svg::puretext::{ + parsers::{dob_output_parser, render_text_params_parser}, + render::render_text_parser_result_to_svg, + }, types::{Error, StandardDOBOutput}, }; @@ -90,19 +94,21 @@ impl DOBSvgExtractor { }) } - pub async fn extract_svg(mut self) -> Result, Error> { - if let Some(svg) = self.extract_svg_from_legacy_dob0().await? { - return Ok(Some(svg)); + pub async fn extract_svg(mut self) -> Result { + if let Some(svg) = self.extract_svg_from_dob0().await? { + return Ok(svg); } if let Some(dob1_svg) = self.extract_svg_from_dob1().await? { - return Ok(Some(dob1_svg)); + return Ok(dob1_svg); } - Ok(None) + let text_svg = self.extract_svg_from_dob0_text()?; + + Ok(text_svg) } - async fn extract_svg_from_legacy_dob0(&mut self) -> Result, Error> { + async fn extract_svg_from_dob0(&mut self) -> Result, Error> { let fsurl = self.parsed_dob.iter().find_map(|dob| { if dob.name == DOB0_TRAIT_NAME { if let Some(dob_trait) = dob.traits.iter().find(|value| value.type_ == "String") { @@ -152,6 +158,17 @@ impl DOBSvgExtractor { } } + fn extract_svg_from_dob0_text(&mut self) -> Result { + let dob_output_result = dob_output_parser(&self.parsed_dob); + let text_render_result = render_text_params_parser( + &dob_output_result.traits, + &dob_output_result.index_var_register, + None, + ); + let svg = render_text_parser_result_to_svg(&text_render_result); + Ok(svg) + } + async fn replace_svg_fsurls(&mut self, svg_content: String) -> Result, Error> { // Create regex patterns to match btcfs:// and ipfs:// URLs in href attributes let btcfs_pattern = regex!(r#"href='btcfs://([^']+)'"#); diff --git a/src/svg/puretext/parsers/mod.rs b/src/svg/puretext/parsers/mod.rs index 32881ad..8d6e8bf 100644 --- a/src/svg/puretext/parsers/mod.rs +++ b/src/svg/puretext/parsers/mod.rs @@ -1,4 +1,9 @@ -pub mod background_parser; -pub mod style_parser; -pub mod text_parser; -pub mod traits_parser; +mod background_parser; +mod style_parser; +mod text_parser; +mod traits_parser; + +pub use background_parser::*; +pub use style_parser::*; +pub use text_parser::*; +pub use traits_parser::*; diff --git a/src/svg/puretext/parsers/style_parser.rs b/src/svg/puretext/parsers/style_parser.rs index 4310451..f8464ae 100644 --- a/src/svg/puretext/parsers/style_parser.rs +++ b/src/svg/puretext/parsers/style_parser.rs @@ -34,16 +34,11 @@ impl Default for ParsedStyle { } } +#[derive(Default)] pub struct StyleParserOptions { pub base_style: Option, } -impl Default for StyleParserOptions { - fn default() -> Self { - Self { base_style: None } - } -} - pub fn style_parser(input: &str, options: Option) -> ParsedStyle { let mut text = input.to_string(); let mut result = options.and_then(|opt| opt.base_style).unwrap_or_default(); diff --git a/src/svg/puretext/parsers/text_parser.rs b/src/svg/puretext/parsers/text_parser.rs index 0c233cd..474f794 100644 --- a/src/svg/puretext/parsers/text_parser.rs +++ b/src/svg/puretext/parsers/text_parser.rs @@ -5,30 +5,20 @@ use serde_json::Value; use crate::svg::puretext::{ constants::{key::Key, regex::*}, parsers::{ - background_parser::{background_color_parser, BackgroundColorOptions}, - style_parser::{ - style_parser, ParsedStyle, ParsedStyleAlignment, ParsedStyleFormat, StyleParserOptions, - }, + background_color_parser, style_parser, BackgroundColorOptions, ParsedStyle, + ParsedStyleAlignment, ParsedStyleFormat, StyleParserOptions, }, SimpleDOBOutput, TraitExt as _, }; pub const DEFAULT_TEMPLATE: &str = "%k: %v"; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct TextParserOptions { pub default_template: Option, } -impl Default for TextParserOptions { - fn default() -> Self { - Self { - default_template: None, - } - } -} - -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct StyleCss { pub text_align: Option, pub color: Option, @@ -37,24 +27,9 @@ pub struct StyleCss { pub text_decoration: Option, } -impl Default for StyleCss { - fn default() -> Self { - Self { - text_align: None, - color: None, - font_weight: None, - font_style: None, - text_decoration: None, - } - } -} - #[derive(Debug, Clone)] pub struct TextItem { - pub name: String, - pub value: Value, pub parsed_style: ParsedStyle, - pub template: String, pub text: String, pub style: StyleCss, } @@ -121,7 +96,7 @@ pub fn render_text_params_parser( // Parse value for layout and style if let Some(value_str) = trait_.get_string_value() { - if let Some(captures) = TEMPLATE_REG.captures(&value_str) { + if let Some(captures) = TEMPLATE_REG.captures(value_str) { if let Some(value_match) = captures.get(1) { processed_value = Value::String(value_match.as_str().to_string()); } @@ -191,10 +166,7 @@ pub fn render_text_params_parser( } TextItem { - name: processed_name, - value: processed_value, parsed_style, - template: current_template, text, style: style_css, } diff --git a/src/svg/puretext/parsers/traits_parser.rs b/src/svg/puretext/parsers/traits_parser.rs index f925a0a..ef9f6c8 100644 --- a/src/svg/puretext/parsers/traits_parser.rs +++ b/src/svg/puretext/parsers/traits_parser.rs @@ -85,7 +85,7 @@ pub fn dob_output_parser(items: &[StandardDOBOutput]) -> TraitsParserResult { // Convert to ISO string format let timestamp_ms = timestamp as i64; let datetime = chrono::DateTime::from_timestamp_millis(timestamp_ms) - .unwrap_or_else(|| chrono::Utc::now()); + .unwrap_or_else(chrono::Utc::now); let iso_string = datetime.to_rfc3339(); return Some(SimpleDOBOutput { diff --git a/src/svg/puretext/render.rs b/src/svg/puretext/render.rs index 9914382..2fe75a9 100644 --- a/src/svg/puretext/render.rs +++ b/src/svg/puretext/render.rs @@ -1,5 +1,4 @@ -use crate::svg::puretext::parsers::style_parser::ParsedStyleAlignment; -use crate::svg::puretext::parsers::text_parser::{TextItem, TextParserResult}; +use crate::svg::puretext::parsers::{ParsedStyleAlignment, TextItem, TextParserResult}; #[derive(Debug, Clone)] pub struct RenderProps { @@ -18,7 +17,6 @@ impl From for RenderProps { #[derive(Debug, Clone)] pub struct RenderElement { - pub key: String, pub element_type: String, pub props: ElementProps, } @@ -29,39 +27,15 @@ pub struct ElementProps { pub style: StyleProps, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct StyleProps { - pub display: Option, pub justify_content: Option, - pub flex_wrap: Option, - pub width: Option, - pub margin: Option, - pub height: Option, - pub text_align: Option, pub color: Option, pub font_weight: Option, pub font_style: Option, pub text_decoration: Option, } -impl Default for StyleProps { - fn default() -> Self { - Self { - display: None, - justify_content: None, - flex_wrap: None, - width: None, - margin: None, - height: None, - text_align: None, - color: None, - font_weight: None, - font_style: None, - text_decoration: None, - } - } -} - pub fn render_text_svg(props: RenderProps) -> String { // Convert text items to render elements let children = convert_items_to_elements(&props.items); @@ -80,12 +54,10 @@ fn convert_items_to_elements(items: &[TextItem]) -> Vec { ParsedStyleAlignment::Right => "flex-end", }; - let mut style = StyleProps::default(); - style.display = Some("flex".to_string()); - style.justify_content = Some(justify_content.to_string()); - style.flex_wrap = Some("wrap".to_string()); - style.width = Some("100%".to_string()); - style.margin = Some("0".to_string()); + let mut style = StyleProps { + justify_content: Some(justify_content.to_string()), + ..Default::default() + }; // Apply text styling if let Some(color) = &item.style.color { @@ -102,7 +74,6 @@ fn convert_items_to_elements(items: &[TextItem]) -> Vec { } let element = RenderElement { - key: item.name.clone(), element_type: "p".to_string(), props: ElementProps { children: vec![item.text.clone()], @@ -119,17 +90,12 @@ fn convert_items_to_elements(items: &[TextItem]) -> Vec { elements.push(element); // Add additional line breaks - for i in 0..item.parsed_style.break_line { + for _ in 0..item.parsed_style.break_line { let break_element = RenderElement { - key: format!("{}-break-{}", item.name, i), element_type: "p".to_string(), props: ElementProps { children: vec![], - style: StyleProps { - height: Some("36px".to_string()), - margin: Some("0".to_string()), - ..Default::default() - }, + style: Default::default(), }, }; elements.push(break_element); @@ -152,7 +118,7 @@ fn generate_svg(elements: &[RenderElement], bg_color: &str) -> String { let mut used_font_weights = std::collections::HashSet::new(); // Calculate dynamic height based on content with minimum of 500 - let calculated_height = elements.len() * line_height + padding_y; + let calculated_height = elements.len() * line_height + padding_y + 10; let height = std::cmp::max(calculated_height as u32, 500); // First pass: collect used font weights @@ -315,18 +281,19 @@ text { } fn calculate_gradient_coordinates(angle: f64) -> (String, String, String, String) { - // For CSS linear-gradient, 0deg points to the right, 90deg points down + // For CSS linear-gradient, 0deg points up, 90deg points right // We need to calculate the gradient line that goes through the rectangle at the given angle // Normalize angle to 0-360 range let normalized_angle = angle % 360.0; - // Convert angle to radians - let angle_rad = normalized_angle.to_radians(); + // Convert CSS angle to mathematical angle (CSS: 0deg = up, Math: 0deg = right) + // CSS angles are measured clockwise from top, math angles counter-clockwise from right + let math_angle = (90.0 - normalized_angle).to_radians(); // Calculate the direction vector - let dx = angle_rad.sin(); - let dy = angle_rad.cos(); + let dx = math_angle.cos(); + let dy = -math_angle.sin(); // Negative because SVG y-axis is flipped // For a rectangle with width=100% and height=100%, we need to find // the intersection points of the gradient line with the rectangle boundaries @@ -345,7 +312,7 @@ fn calculate_gradient_coordinates(angle: f64) -> (String, String, String, String if dx.abs() > 1e-10 { let t = -center_x / dx; let y = center_y + t * dy; - if y >= 0.0 && y <= 1.0 { + if (0.0..=1.0).contains(&y) { t_values.push((t, 0.0, y)); } } @@ -354,7 +321,7 @@ fn calculate_gradient_coordinates(angle: f64) -> (String, String, String, String if dx.abs() > 1e-10 { let t = (1.0 - center_x) / dx; let y = center_y + t * dy; - if y >= 0.0 && y <= 1.0 { + if (0.0..=1.0).contains(&y) { t_values.push((t, 1.0, y)); } } @@ -363,7 +330,7 @@ fn calculate_gradient_coordinates(angle: f64) -> (String, String, String, String if dy.abs() > 1e-10 { let t = -center_y / dy; let x = center_x + t * dx; - if x >= 0.0 && x <= 1.0 { + if (0.0..=1.0).contains(&x) { t_values.push((t, x, 0.0)); } } @@ -372,7 +339,7 @@ fn calculate_gradient_coordinates(angle: f64) -> (String, String, String, String if dy.abs() > 1e-10 { let t = (1.0 - center_y) / dy; let x = center_x + t * dx; - if x >= 0.0 && x <= 1.0 { + if (0.0..=1.0).contains(&x) { t_values.push((t, x, 1.0)); } } @@ -385,8 +352,7 @@ fn calculate_gradient_coordinates(angle: f64) -> (String, String, String, String let (_, x2, y2) = t_values[t_values.len() - 1]; // Convert to percentage strings - // For CSS linear-gradient, the gradient flows in the direction of the angle - // So we want the start point to be where the gradient begins + // Use the endpoints as calculated to match CSS linear-gradient behavior ( format!("{:.1}%", x1 * 100.0), format!("{:.1}%", y1 * 100.0), @@ -432,20 +398,6 @@ fn escape_xml(text: &str) -> String { .replace("'", "'") } -// Legacy function for backward compatibility -pub fn render_text_params(render_output: Vec) -> String { - use crate::svg::puretext::parsers::{text_parser, traits_parser}; - - let traits_parser_result = traits_parser::dob_output_parser(&render_output); - let text_parser_result = text_parser::render_text_params_parser( - &traits_parser_result.traits, - &traits_parser_result.index_var_register, - None, - ); - - render_text_parser_result_to_svg(&text_parser_result) -} - pub fn render_text_parser_result_to_svg(text_parser_result: &TextParserResult) -> String { let props = RenderProps::from(text_parser_result.clone()); render_text_svg(props) diff --git a/src/tests/dob0/legacy_decoder.rs b/src/tests/dob0/legacy_decoder.rs index 47a6bbd..b374ebc 100644 --- a/src/tests/dob0/legacy_decoder.rs +++ b/src/tests/dob0/legacy_decoder.rs @@ -2,9 +2,7 @@ use ckb_types::{h256, H256}; use crate::client::ImageFetchClient; use crate::decoder::{helpers::decode_spore_content, DOBDecoder}; -use crate::svg::puretext::parsers::{ - text_parser::render_text_params_parser, traits_parser::dob_output_parser, -}; +use crate::svg::puretext::parsers::{dob_output_parser, render_text_params_parser}; use crate::svg::puretext::render::render_text_parser_result_to_svg; use crate::svg::DOBSvgExtractor; use crate::tests::{prepare_settings, SettingType}; @@ -20,6 +18,8 @@ const NERVAPE_SPORE_ID: H256 = h256!("0x9dd9604d44d6640d1533c9f97f89438f17526e645f6c35aa08d8c7d844578580"); const MAINNET_NERVAPE_SPORE_ID: H256 = h256!("0xbbe57f0e7f7ca6e6c59007b28150e39c9c6f5c209493801cfc9ef125e0937ed4"); +const UNICORN_SPORE_ID: H256 = + h256!("0xe5bd5bbf82fec9107ba86fb65b3756915ca0d3a28d5e13a0aa82269b62a129ef"); fn generate_nervape_dob_ingredients(onchain_decoder: bool) -> (Value, ClusterDescriptionField) { let nervape_content = json!({ @@ -212,11 +212,7 @@ async fn test_fetch_and_decode_mainnet_nervape_dna_to_svg() { .await .expect("decode"); let svg_extractor = DOBSvgExtractor::new(render_result, image_fetcher).unwrap(); - let svg_content = svg_extractor - .extract_svg() - .await - .unwrap() - .unwrap_or_default(); + let svg_content = svg_extractor.extract_svg().await.unwrap(); println!("svg_content: {svg_content}"); } @@ -312,3 +308,21 @@ fn test_manual_render_output_to_svg() { let svg = render_text_parser_result_to_svg(&text_parser_result); println!("\nGenerated SVG:\n{}", svg); } + +#[tokio::test] +async fn test_decode_mainnet_unicorn_to_svg() { + let settings = prepare_settings(SettingType::Mainnet, vec![]); + let image_fetcher = ImageFetchClient::new(&settings.image_fetcher_url, 10); + let decoder = DOBDecoder::new(settings); + let (_, dna, dob_metadata) = decoder + .fetch_decode_ingredients(UNICORN_SPORE_ID.into()) + .await + .expect("fetch"); + let render_result = decoder + .decode_dna(&dna, dob_metadata) + .await + .expect("decode"); + let svg_extractor = DOBSvgExtractor::new(render_result, image_fetcher).unwrap(); + let svg_content = svg_extractor.extract_svg().await.unwrap(); + println!("svg_content: {svg_content}"); +} diff --git a/src/tests/dob1/decoder.rs b/src/tests/dob1/decoder.rs index 6f78220..7a76807 100644 --- a/src/tests/dob1/decoder.rs +++ b/src/tests/dob1/decoder.rs @@ -79,10 +79,6 @@ async fn test_mainnet_dob1_decode_to_svg() { let dna = content.get("dna").unwrap().as_str().unwrap(); let render_result = decoder.decode_dna(dna, dob_metadata).await.expect("decode"); let svg_extractor = DOBSvgExtractor::new(render_result, image_fetcher).unwrap(); - let svg_content = svg_extractor - .extract_svg() - .await - .unwrap() - .unwrap_or_default(); + let svg_content = svg_extractor.extract_svg().await.unwrap(); println!("svg_content: {svg_content}"); } From 9632287ec08b7f451285b59ce49700d8021b314c Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Thu, 28 Aug 2025 17:39:17 +0800 Subject: [PATCH 07/26] chore: solve gemini assistant report --- src/client.rs | 82 +++++++---------------- src/decoder/helpers.rs | 5 +- src/server.rs | 18 ++--- src/svg/mod.rs | 65 +++++++++++------- src/svg/puretext/parsers/traits_parser.rs | 3 +- src/svg/puretext/render.rs | 2 +- src/tests/dob0/legacy_decoder.rs | 11 ++- src/tests/dob1/decoder.rs | 6 +- src/types.rs | 42 +++++++++++- 9 files changed, 124 insertions(+), 110 deletions(-) diff --git a/src/client.rs b/src/client.rs index 1fc12a5..0b251d4 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, VecDeque}; +use std::collections::HashMap; use std::future::Future; use std::pin::Pin; use std::sync::atomic::{AtomicU64, Ordering}; @@ -156,24 +156,18 @@ impl RpcClient { pub struct ImageFetchClient { base_url: HashMap, - images_cache: VecDeque<(Url, Vec)>, - max_cache_size: usize, } +unsafe impl Sync for ImageFetchClient {} + impl ImageFetchClient { - pub fn new(base_url: &HashMap, cache_size: usize) -> Self { - let base_url = base_url - .iter() - .map(|(k, v)| (k.clone(), Url::parse(v).expect("url"))) - .collect::>(); + pub fn new(base_url: &HashMap) -> Self { Self { - base_url, - images_cache: VecDeque::new(), - max_cache_size: cache_size, + base_url: base_url.clone(), } } - pub async fn fetch_images(&mut self, images_uri: &[String]) -> Result>, Error> { + pub async fn fetch_images(&self, images_uri: &[String]) -> Result>, Error> { let mut requests = vec![]; for uri in images_uri { match uri.try_into()? { @@ -184,18 +178,7 @@ impl ImageFetchClient { .ok_or(Error::FsuriNotFoundInConfig)? .join(&tx_hash) .expect("image url"); - let cached_image = self.images_cache.iter().find(|(v, _)| v == &url); - if let Some((_, image)) = cached_image { - requests.push(async { Ok((url, true, image.clone())) }.boxed()); - } else { - requests.push( - async move { - let image = parse_image_from_btcfs(&url, index).await?; - Ok((url, false, image)) - } - .boxed(), - ); - } + requests.push(parse_image_from_btcfs(url, index).boxed()); } URI::IPFS(cid) => { let url = self @@ -204,38 +187,26 @@ impl ImageFetchClient { .ok_or(Error::FsuriNotFoundInConfig)? .join(&cid) .expect("image url"); - let cached_image = self.images_cache.iter().find(|(v, _)| v == &url); - if let Some((_, image)) = cached_image { - requests.push(async { Ok((url, true, image.clone())) }.boxed()); - } else { - requests.push( - async move { - let image = reqwest::get(url.clone()) - .await - .map_err(|e| Error::FetchFromIpfsError(e.to_string()))? - .bytes() - .await - .map_err(|e| Error::FetchFromIpfsError(e.to_string()))? - .to_vec(); - Ok((url, false, image)) - } - .boxed(), - ); - } + requests.push( + async move { + let image = reqwest::get(url.clone()) + .await + .map_err(|e| Error::FetchFromIpfsError(e.to_string()))? + .bytes() + .await + .map_err(|e| Error::FetchFromIpfsError(e.to_string()))? + .to_vec(); + Ok(image) + } + .boxed(), + ); } } } let mut images = vec![]; let responses = futures::future::join_all(requests).await; for response in responses { - let (url, from_cache, result) = response?; - images.push(result.to_vec()); - if !from_cache { - self.images_cache.push_back((url, result)); - if self.images_cache.len() > self.max_cache_size { - self.images_cache.pop_front(); - } - } + images.push(response?); } Ok(images) } @@ -251,8 +222,7 @@ impl TryFrom<&String> for URI { type Error = Error; fn try_from(uri: &String) -> Result { - if uri.starts_with("btcfs://") { - let body = uri.chars().skip("btcfs://".len()).collect::(); + if let Some(body) = uri.strip_prefix("btcfs://") { let parts: Vec<&str> = body.split('i').collect::>(); if parts.len() != 2 { return Err(Error::InvalidOnchainFsuriFormat); @@ -262,8 +232,8 @@ impl TryFrom<&String> for URI { .parse() .map_err(|_| Error::InvalidOnchainFsuriFormat)?; Ok(URI::BTCFS(tx_hash, index)) - } else if uri.starts_with("ipfs://") { - let hash = uri.chars().skip("ipfs://".len()).collect::(); + } else if let Some(body) = uri.strip_prefix("ipfs://") { + let hash = body.to_string(); Ok(URI::IPFS(hash)) } else { Err(Error::InvalidOnchainFsuriFormat) @@ -271,9 +241,9 @@ impl TryFrom<&String> for URI { } } -async fn parse_image_from_btcfs(url: &Url, index: usize) -> Result, Error> { +async fn parse_image_from_btcfs(url: Url, index: usize) -> Result, Error> { // parse btc transaction - let btc_tx = reqwest::get(url.clone()) + let btc_tx = reqwest::get(url) .await .map_err(|e| Error::FetchFromBtcNodeError(e.to_string()))? .json::() diff --git a/src/decoder/helpers.rs b/src/decoder/helpers.rs index 3602a42..58394de 100644 --- a/src/decoder/helpers.rs +++ b/src/decoder/helpers.rs @@ -103,9 +103,8 @@ pub fn decode_spore_content(content: &[u8]) -> Result<(Value, String), Error> { return Ok((serde_json::Value::String(dna.clone()), dna)); } - let value: Value = serde_json::from_slice(content).unwrap_or(Value::String( - String::from_utf8(content.to_vec()).expect("raw string content"), - )); + let value: Value = serde_json::from_slice(content) + .unwrap_or(Value::String(String::from_utf8_lossy(content).to_string())); let dna = match &value { serde_json::Value::String(_) => &value, serde_json::Value::Array(array) => array.first().ok_or(Error::DOBContentUnexpected)?, diff --git a/src/server.rs b/src/server.rs index 7c557f2..e014585 100644 --- a/src/server.rs +++ b/src/server.rs @@ -8,7 +8,6 @@ use jsonrpsee::{proc_macros::rpc, tracing, types::error::ErrorObjectOwned}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::client::ImageFetchClient; use crate::decoder::helpers::{decode_cluster_data, decode_spore_data}; use crate::decoder::DOBDecoder; use crate::svg::DOBSvgExtractor; @@ -56,11 +55,13 @@ trait DecoderRpc { pub struct DecoderStandaloneServer { decoder: DOBDecoder, cache_expiration: u64, + svg_extractor: DOBSvgExtractor, } impl DecoderStandaloneServer { pub fn new(decoder: DOBDecoder, cache_expiration: u64) -> Self { Self { + svg_extractor: DOBSvgExtractor::new(&decoder.setting().image_fetcher_url), decoder, cache_expiration, } @@ -155,10 +156,7 @@ impl DecoderRpcServer for DecoderStandaloneServer { render_output, dob_content: _, } = serde_json::from_str(&self.decode(hexed_spore_id).await?).unwrap(); - let image_fetcher = ImageFetchClient::new(&self.decoder.setting().image_fetcher_url, 10); - let svg = DOBSvgExtractor::new(render_output, image_fetcher)? - .extract_svg() - .await?; + let svg = self.svg_extractor.extract_svg(render_output).await?; Ok(svg) } @@ -167,10 +165,12 @@ impl DecoderRpcServer for DecoderStandaloneServer { fsuri: String, encode_type: Option, ) -> Result { - let mut image_fetcher = - ImageFetchClient::new(&self.decoder.setting().image_fetcher_url, 10); - let raw_images = image_fetcher.fetch_images(&[fsuri]).await?; - let image = raw_images.first().unwrap(); + let raw_images = self + .svg_extractor + .get_fetcher() + .fetch_images(&[fsuri]) + .await?; + let image = raw_images.first().ok_or(Error::NoImageFound)?; match encode_type.as_deref() { Some("hex") => Ok(hex::encode(image)), diff --git a/src/svg/mod.rs b/src/svg/mod.rs index caeef37..38a3a42 100644 --- a/src/svg/mod.rs +++ b/src/svg/mod.rs @@ -1,5 +1,8 @@ +use std::collections::HashMap; + use base64::{engine::general_purpose::STANDARD, Engine}; use lazy_regex::regex; +use reqwest::Url; use serde_json::Value; use crate::{ @@ -81,35 +84,39 @@ pub fn detect_image_mime_type(hex_content: String) -> Option<&'static str> { pub struct DOBSvgExtractor { fetcher: ImageFetchClient, - parsed_dob: Vec, } impl DOBSvgExtractor { - pub fn new(dob_render_output: String, fetcher: ImageFetchClient) -> Result { + pub fn new(base_url: &HashMap) -> Self { + Self { + fetcher: ImageFetchClient::new(base_url), + } + } + + pub fn get_fetcher(&self) -> &ImageFetchClient { + &self.fetcher + } + + pub async fn extract_svg(&self, dob_render_output: String) -> Result { let parsed_dob: Vec = serde_json::from_str(&dob_render_output).map_err(|_| Error::DOBRenderOutputInvalid)?; - Ok(Self { - fetcher, - parsed_dob, - }) - } - pub async fn extract_svg(mut self) -> Result { - if let Some(svg) = self.extract_svg_from_dob0().await? { - return Ok(svg); + if let Some(dob0_svg) = self.extract_png_svg_from_dob0(&parsed_dob).await? { + return Ok(dob0_svg); } - if let Some(dob1_svg) = self.extract_svg_from_dob1().await? { + if let Some(dob1_svg) = self.extract_png_svg_from_dob1(&parsed_dob).await? { return Ok(dob1_svg); } - let text_svg = self.extract_svg_from_dob0_text()?; - - Ok(text_svg) + self.extract_text_svg_from_dob0(&parsed_dob) } - async fn extract_svg_from_dob0(&mut self) -> Result, Error> { - let fsurl = self.parsed_dob.iter().find_map(|dob| { + async fn extract_png_svg_from_dob0( + &self, + parsed_dob: &[StandardDOBOutput], + ) -> Result, Error> { + let fsurl = parsed_dob.iter().find_map(|dob| { if dob.name == DOB0_TRAIT_NAME { if let Some(dob_trait) = dob.traits.iter().find(|value| value.type_ == "String") { if let Value::String(fsurl) = &dob_trait.value { @@ -126,7 +133,9 @@ impl DOBSvgExtractor { .fetcher .fetch_images(&[dob0_fsurl.clone()]) .await? - .remove(0); + .into_iter() + .next() + .ok_or(Error::FetchFromIpfsError("No image found".to_string()))?; let Some(image_mime_type) = detect_image_mime_type(hex::encode(&image_content)) else { return Ok(None); }; @@ -140,8 +149,11 @@ impl DOBSvgExtractor { } } - async fn extract_svg_from_dob1(&mut self) -> Result, Error> { - let svg = self.parsed_dob.iter().find_map(|dob| { + async fn extract_png_svg_from_dob1( + &self, + parsed_dob: &[StandardDOBOutput], + ) -> Result, Error> { + let svg = parsed_dob.iter().find_map(|dob| { if dob.name == DOB1_TRAIT_NAME { if let Some(dob_trait) = dob.traits.iter().find(|value| value.type_ == "SVG") { if let Value::String(svg) = &dob_trait.value { @@ -158,8 +170,11 @@ impl DOBSvgExtractor { } } - fn extract_svg_from_dob0_text(&mut self) -> Result { - let dob_output_result = dob_output_parser(&self.parsed_dob); + fn extract_text_svg_from_dob0( + &self, + parsed_dob: &[StandardDOBOutput], + ) -> Result { + let dob_output_result = dob_output_parser(parsed_dob); let text_render_result = render_text_params_parser( &dob_output_result.traits, &dob_output_result.index_var_register, @@ -169,13 +184,11 @@ impl DOBSvgExtractor { Ok(svg) } - async fn replace_svg_fsurls(&mut self, svg_content: String) -> Result, Error> { + async fn replace_svg_fsurls(&self, mut svg_content: String) -> Result, Error> { // Create regex patterns to match btcfs:// and ipfs:// URLs in href attributes let btcfs_pattern = regex!(r#"href='btcfs://([^']+)'"#); let ipfs_pattern = regex!(r#"href='ipfs://([^']+)'"#); - let mut processed_svg = svg_content.clone(); - // Find all btcfs URLs let btcfs_urls: Vec = btcfs_pattern .captures_iter(&svg_content) @@ -211,9 +224,9 @@ impl DOBSvgExtractor { // Replace the URL in the SVG let old_href = format!("href='{}'", url); let new_href = format!("href='{}'", data_url); - processed_svg = processed_svg.replace(&old_href, &new_href); + svg_content = svg_content.replace(&old_href, &new_href); } - Ok(Some(processed_svg)) + Ok(Some(svg_content)) } } diff --git a/src/svg/puretext/parsers/traits_parser.rs b/src/svg/puretext/parsers/traits_parser.rs index ef9f6c8..8ce65ea 100644 --- a/src/svg/puretext/parsers/traits_parser.rs +++ b/src/svg/puretext/parsers/traits_parser.rs @@ -84,8 +84,7 @@ pub fn dob_output_parser(items: &[StandardDOBOutput]) -> TraitsParserResult { // Convert to ISO string format let timestamp_ms = timestamp as i64; - let datetime = chrono::DateTime::from_timestamp_millis(timestamp_ms) - .unwrap_or_else(chrono::Utc::now); + let datetime = chrono::DateTime::from_timestamp_millis(timestamp_ms)?; let iso_string = datetime.to_rfc3339(); return Some(SimpleDOBOutput { diff --git a/src/svg/puretext/render.rs b/src/svg/puretext/render.rs index 2fe75a9..a5f24aa 100644 --- a/src/svg/puretext/render.rs +++ b/src/svg/puretext/render.rs @@ -233,7 +233,7 @@ fn create_gradient_background(gradient_css: &str, width: u32, height: u32) -> (S // Create background rect with gradient fill let background_rect = format!( - r#""#, + r#""#, width, height, gradient_id ); diff --git a/src/tests/dob0/legacy_decoder.rs b/src/tests/dob0/legacy_decoder.rs index b374ebc..e7473b2 100644 --- a/src/tests/dob0/legacy_decoder.rs +++ b/src/tests/dob0/legacy_decoder.rs @@ -1,6 +1,5 @@ use ckb_types::{h256, H256}; -use crate::client::ImageFetchClient; use crate::decoder::{helpers::decode_spore_content, DOBDecoder}; use crate::svg::puretext::parsers::{dob_output_parser, render_text_params_parser}; use crate::svg::puretext::render::render_text_parser_result_to_svg; @@ -200,7 +199,7 @@ async fn test_unicorn_dna_to_svg() { #[tokio::test] async fn test_fetch_and_decode_mainnet_nervape_dna_to_svg() { let settings = prepare_settings(SettingType::Mainnet, vec![]); - let image_fetcher = ImageFetchClient::new(&settings.image_fetcher_url, 10); + let svg_extractor = DOBSvgExtractor::new(&settings.image_fetcher_url); let decoder = DOBDecoder::new(settings); let (_, dna, dob_metadata) = decoder .fetch_decode_ingredients(MAINNET_NERVAPE_SPORE_ID.into()) @@ -211,8 +210,7 @@ async fn test_fetch_and_decode_mainnet_nervape_dna_to_svg() { // array type .await .expect("decode"); - let svg_extractor = DOBSvgExtractor::new(render_result, image_fetcher).unwrap(); - let svg_content = svg_extractor.extract_svg().await.unwrap(); + let svg_content = svg_extractor.extract_svg(render_result).await.unwrap(); println!("svg_content: {svg_content}"); } @@ -312,7 +310,7 @@ fn test_manual_render_output_to_svg() { #[tokio::test] async fn test_decode_mainnet_unicorn_to_svg() { let settings = prepare_settings(SettingType::Mainnet, vec![]); - let image_fetcher = ImageFetchClient::new(&settings.image_fetcher_url, 10); + let svg_extractor = DOBSvgExtractor::new(&settings.image_fetcher_url); let decoder = DOBDecoder::new(settings); let (_, dna, dob_metadata) = decoder .fetch_decode_ingredients(UNICORN_SPORE_ID.into()) @@ -322,7 +320,6 @@ async fn test_decode_mainnet_unicorn_to_svg() { .decode_dna(&dna, dob_metadata) .await .expect("decode"); - let svg_extractor = DOBSvgExtractor::new(render_result, image_fetcher).unwrap(); - let svg_content = svg_extractor.extract_svg().await.unwrap(); + let svg_content = svg_extractor.extract_svg(render_result).await.unwrap(); println!("svg_content: {svg_content}"); } diff --git a/src/tests/dob1/decoder.rs b/src/tests/dob1/decoder.rs index 7a76807..55ce15d 100644 --- a/src/tests/dob1/decoder.rs +++ b/src/tests/dob1/decoder.rs @@ -2,7 +2,6 @@ use ckb_types::h256; use serde_json::{json, Value}; use crate::{ - client::ImageFetchClient, decoder::DOBDecoder, svg::DOBSvgExtractor, tests::{prepare_settings, SettingType}, @@ -73,12 +72,11 @@ async fn test_dob1_basic_decode() { #[tokio::test] async fn test_mainnet_dob1_decode_to_svg() { let settings = prepare_settings(SettingType::Mainnet, vec![]); - let image_fetcher = ImageFetchClient::new(&settings.image_fetcher_url, 10); + let svg_extractor = DOBSvgExtractor::new(&settings.image_fetcher_url); let (content, dob_metadata) = generate_dob1_ingredients(); let decoder = DOBDecoder::new(settings); let dna = content.get("dna").unwrap().as_str().unwrap(); let render_result = decoder.decode_dna(dna, dob_metadata).await.expect("decode"); - let svg_extractor = DOBSvgExtractor::new(render_result, image_fetcher).unwrap(); - let svg_content = svg_extractor.extract_svg().await.unwrap(); + let svg_content = svg_extractor.extract_svg(render_result).await.unwrap(); println!("svg_content: {svg_content}"); } diff --git a/src/types.rs b/src/types.rs index f11de2d..ac69d87 100644 --- a/src/types.rs +++ b/src/types.rs @@ -2,7 +2,8 @@ use std::{collections::HashMap, path::PathBuf}; use ckb_jsonrpc_types::Script; use ckb_types::{core::ScriptHashType, H256}; -use serde::{ser::SerializeMap, Deserialize}; +use reqwest::Url; +use serde::{ser::SerializeMap, Deserialize, Deserializer, Serializer}; use serde_json::Value; #[cfg(feature = "standalone_server")] @@ -93,6 +94,8 @@ pub enum Error { FsuriNotFoundInConfig, #[error("IPFS Gateway responsed badly with error: {0}")] FetchFromIpfsError(String), + #[error("No image found")] + NoImageFound, #[error("DOB render output is not in format of DOB protocol")] DOBRenderOutputInvalid, } @@ -274,7 +277,14 @@ pub struct ScriptId { pub struct Settings { pub protocol_versions: Vec, pub ckb_rpc: String, - pub image_fetcher_url: HashMap, + #[cfg_attr( + feature = "standalone_server", + serde( + serialize_with = "serialize_fetcher", + deserialize_with = "deserialize_fetcher" + ) + )] + pub image_fetcher_url: HashMap, pub rpc_server_address: String, pub decoders_cache_directory: PathBuf, pub dobs_cache_directory: PathBuf, @@ -298,6 +308,7 @@ pub struct ParsedTrait { pub value: Value, } +#[cfg(feature = "standalone_server")] impl Serialize for ParsedTrait { fn serialize(&self, serializer: S) -> Result where @@ -309,6 +320,7 @@ impl Serialize for ParsedTrait { } } +#[cfg(feature = "standalone_server")] impl<'de> Deserialize<'de> for ParsedTrait { fn deserialize(deserializer: D) -> Result where @@ -326,3 +338,29 @@ impl<'de> Deserialize<'de> for ParsedTrait { .unwrap_or_else(|| Err(serde::de::Error::custom("invalid ParsedTrait"))) } } + +#[cfg(feature = "standalone_server")] +fn serialize_fetcher(urls: &HashMap, serializer: S) -> Result +where + S: Serializer, +{ + let mut map = serializer.serialize_map(Some(urls.len()))?; + for (key, url) in urls { + map.serialize_entry(key, &url.as_str())?; + } + map.end() +} + +#[cfg(feature = "standalone_server")] +fn deserialize_fetcher<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let map: HashMap = HashMap::deserialize(deserializer)?; + let mut urls = HashMap::new(); + for (key, url_str) in map { + let url = Url::parse(&url_str).map_err(serde::de::Error::custom)?; + urls.insert(key, url); + } + Ok(urls) +} From b94e031095ebea4a7689e9799e3a6454a56024bb Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Thu, 28 Aug 2025 18:01:32 +0800 Subject: [PATCH 08/26] feat: adjust direction structure of puretext and add 24 hours time expiration for typed decoder --- src/decoder/helpers.rs | 8 +++ src/svg/puretext/constants.rs | 54 +++++++++++++++++++ src/svg/puretext/constants/key.rs | 29 ---------- src/svg/puretext/constants/mod.rs | 2 - src/svg/puretext/constants/regex.rs | 26 --------- src/svg/puretext/parsers/background_parser.rs | 2 +- src/svg/puretext/parsers/text_parser.rs | 2 +- src/svg/puretext/parsers/traits_parser.rs | 2 +- 8 files changed, 65 insertions(+), 60 deletions(-) create mode 100644 src/svg/puretext/constants.rs delete mode 100644 src/svg/puretext/constants/key.rs delete mode 100644 src/svg/puretext/constants/mod.rs delete mode 100644 src/svg/puretext/constants/regex.rs diff --git a/src/decoder/helpers.rs b/src/decoder/helpers.rs index 58394de..8abf471 100644 --- a/src/decoder/helpers.rs +++ b/src/decoder/helpers.rs @@ -32,7 +32,11 @@ fn build_type_script_search_option(type_script: Script) -> CellQueryOptions { CellQueryOptions::new_type(type_script) } +<<<<<<< HEAD fn file_older_than_minutes(file_path: &PathBuf, minutes: u64) -> bool { +======= +fn file_older_than_hours(file_path: &PathBuf, hours: u64) -> bool { +>>>>>>> 26d7805 (feat: adjust direction structure of puretext and add 24 hours time expiration for typed decoder) match std::fs::metadata(file_path) { Ok(metadata) => { let Ok(mut duration) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) else { @@ -49,7 +53,11 @@ fn file_older_than_minutes(file_path: &PathBuf, minutes: u64) -> bool { { duration = duration.saturating_sub(checkpoint); } +<<<<<<< HEAD duration.as_secs() / 60 >= minutes +======= + duration.as_secs() / 3600 >= hours +>>>>>>> 26d7805 (feat: adjust direction structure of puretext and add 24 hours time expiration for typed decoder) } Err(_) => true, } diff --git a/src/svg/puretext/constants.rs b/src/svg/puretext/constants.rs new file mode 100644 index 0000000..c2f71e9 --- /dev/null +++ b/src/svg/puretext/constants.rs @@ -0,0 +1,54 @@ +use lazy_regex::{lazy_regex, regex, Lazy}; +use serde_json::Value; + +pub static ARRAY_REG: Lazy = lazy_regex!(r"\%(.*?)\):(\[.*?\])"); +pub static ARRAY_INDEX_REG: Lazy = lazy_regex!(r"(\d+)<_>$"); +pub static GLOBAL_TEMPLATE_REG: Lazy = lazy_regex!(r"^prev<(.*?)>"); +pub static TEMPLATE_REG: Lazy = lazy_regex!(r"^(.*?)<(.*?)>"); + +pub fn parse_string_to_array(s: &str) -> Vec { + // This regex matches anything inside single quotes: '...' + let re = regex::Regex::new(r"'([^']*)'").unwrap(); + re.captures_iter(s) + .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string())) + .collect() +} + +pub fn parse_value_to_string(val: &Value) -> String { + match val { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + _ => String::new(), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Key { + BgColor, + Prev, + Image, +} + +impl std::fmt::Display for Key { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Key::BgColor => write!(f, "prev.bgcolor"), + Key::Prev => write!(f, "prev"), + Key::Image => write!(f, "IMAGE"), + } + } +} + +impl std::str::FromStr for Key { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "prev.bgcolor" => Ok(Key::BgColor), + "prev" => Ok(Key::Prev), + "IMAGE" => Ok(Key::Image), + _ => Err(format!("Unknown key: {}", s)), + } + } +} diff --git a/src/svg/puretext/constants/key.rs b/src/svg/puretext/constants/key.rs deleted file mode 100644 index 8b5530f..0000000 --- a/src/svg/puretext/constants/key.rs +++ /dev/null @@ -1,29 +0,0 @@ -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Key { - BgColor, - Prev, - Image, -} - -impl std::fmt::Display for Key { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Key::BgColor => write!(f, "prev.bgcolor"), - Key::Prev => write!(f, "prev"), - Key::Image => write!(f, "IMAGE"), - } - } -} - -impl std::str::FromStr for Key { - type Err = String; - - fn from_str(s: &str) -> Result { - match s { - "prev.bgcolor" => Ok(Key::BgColor), - "prev" => Ok(Key::Prev), - "IMAGE" => Ok(Key::Image), - _ => Err(format!("Unknown key: {}", s)), - } - } -} diff --git a/src/svg/puretext/constants/mod.rs b/src/svg/puretext/constants/mod.rs deleted file mode 100644 index 3630d6a..0000000 --- a/src/svg/puretext/constants/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod key; -pub mod regex; diff --git a/src/svg/puretext/constants/regex.rs b/src/svg/puretext/constants/regex.rs deleted file mode 100644 index 432f1b8..0000000 --- a/src/svg/puretext/constants/regex.rs +++ /dev/null @@ -1,26 +0,0 @@ -use lazy_regex::regex; -use serde_json::Value; - -pub static ARRAY_REG: lazy_regex::Lazy = - lazy_regex::lazy_regex!(r"\%(.*?)\):(\[.*?\])"); -pub static ARRAY_INDEX_REG: lazy_regex::Lazy = lazy_regex::lazy_regex!(r"(\d+)<_>$"); -pub static GLOBAL_TEMPLATE_REG: lazy_regex::Lazy = - lazy_regex::lazy_regex!(r"^prev<(.*?)>"); -pub static TEMPLATE_REG: lazy_regex::Lazy = lazy_regex::lazy_regex!(r"^(.*?)<(.*?)>"); - -pub fn parse_string_to_array(s: &str) -> Vec { - // This regex matches anything inside single quotes: '...' - let re = regex::Regex::new(r"'([^']*)'").unwrap(); - re.captures_iter(s) - .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string())) - .collect() -} - -pub fn parse_value_to_string(val: &Value) -> String { - match val { - Value::String(s) => s.clone(), - Value::Number(n) => n.to_string(), - Value::Bool(b) => b.to_string(), - _ => String::new(), - } -} diff --git a/src/svg/puretext/parsers/background_parser.rs b/src/svg/puretext/parsers/background_parser.rs index 720ca77..c19a174 100644 --- a/src/svg/puretext/parsers/background_parser.rs +++ b/src/svg/puretext/parsers/background_parser.rs @@ -1,4 +1,4 @@ -use crate::svg::puretext::{constants::key::Key, SimpleDOBOutput, TraitExt as _}; +use crate::svg::puretext::{constants::Key, SimpleDOBOutput, TraitExt as _}; pub fn get_background_color_by_traits(traits: &[SimpleDOBOutput]) -> Option<&SimpleDOBOutput> { traits diff --git a/src/svg/puretext/parsers/text_parser.rs b/src/svg/puretext/parsers/text_parser.rs index 474f794..e19001c 100644 --- a/src/svg/puretext/parsers/text_parser.rs +++ b/src/svg/puretext/parsers/text_parser.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use serde_json::Value; use crate::svg::puretext::{ - constants::{key::Key, regex::*}, + constants::{parse_value_to_string, Key, GLOBAL_TEMPLATE_REG, TEMPLATE_REG}, parsers::{ background_color_parser, style_parser, BackgroundColorOptions, ParsedStyle, ParsedStyleAlignment, ParsedStyleFormat, StyleParserOptions, diff --git a/src/svg/puretext/parsers/traits_parser.rs b/src/svg/puretext/parsers/traits_parser.rs index 8ce65ea..eb021e4 100644 --- a/src/svg/puretext/parsers/traits_parser.rs +++ b/src/svg/puretext/parsers/traits_parser.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use crate::{ svg::puretext::{ - constants::regex::{parse_string_to_array, ARRAY_INDEX_REG, ARRAY_REG}, + constants::{parse_string_to_array, ARRAY_INDEX_REG, ARRAY_REG}, SimpleDOBOutput, TraitExt, }, types::{ParsedTrait, StandardDOBOutput}, From 36c84ac50e04f38dfb16b23ecc6bad70420657e8 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Thu, 28 Aug 2025 18:33:22 +0800 Subject: [PATCH 09/26] chore: add config item for decoders expiration --- src/client.rs | 2 -- src/decoder/helpers.rs | 8 -------- 2 files changed, 10 deletions(-) diff --git a/src/client.rs b/src/client.rs index 0b251d4..64d5153 100644 --- a/src/client.rs +++ b/src/client.rs @@ -158,8 +158,6 @@ pub struct ImageFetchClient { base_url: HashMap, } -unsafe impl Sync for ImageFetchClient {} - impl ImageFetchClient { pub fn new(base_url: &HashMap) -> Self { Self { diff --git a/src/decoder/helpers.rs b/src/decoder/helpers.rs index 8abf471..58394de 100644 --- a/src/decoder/helpers.rs +++ b/src/decoder/helpers.rs @@ -32,11 +32,7 @@ fn build_type_script_search_option(type_script: Script) -> CellQueryOptions { CellQueryOptions::new_type(type_script) } -<<<<<<< HEAD fn file_older_than_minutes(file_path: &PathBuf, minutes: u64) -> bool { -======= -fn file_older_than_hours(file_path: &PathBuf, hours: u64) -> bool { ->>>>>>> 26d7805 (feat: adjust direction structure of puretext and add 24 hours time expiration for typed decoder) match std::fs::metadata(file_path) { Ok(metadata) => { let Ok(mut duration) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) else { @@ -53,11 +49,7 @@ fn file_older_than_hours(file_path: &PathBuf, hours: u64) -> bool { { duration = duration.saturating_sub(checkpoint); } -<<<<<<< HEAD duration.as_secs() / 60 >= minutes -======= - duration.as_secs() / 3600 >= hours ->>>>>>> 26d7805 (feat: adjust direction structure of puretext and add 24 hours time expiration for typed decoder) } Err(_) => true, } From 6666e2389275575808e29a97003172f4d4a709a2 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Tue, 9 Sep 2025 14:12:15 +0800 Subject: [PATCH 10/26] chore: fix gemini reports --- README.md | 4 ++-- src/client.rs | 16 ++++++++++------ src/decoder/helpers.rs | 1 + src/server.rs | 7 ++++--- src/svg/mod.rs | 16 ++++++++-------- src/svg/puretext/render.rs | 9 ++++++--- 6 files changed, 31 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 6c1525c..aa3486f 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ $ echo '{ "jsonrpc": "2.0", "method": "dob_decode_svg", "params": [ - "bbe57f0e7f7ca6e6c59007b28150e39c9c6f5c209493801cfc9ef125e0937ed4" + "0x577bf0de0dcffe2811fa827480a700bc800c8e1e9606615b1484baeea2cba830" ] }' \ | curl -H 'content-type: application/json' -d @- \ @@ -85,7 +85,7 @@ http://localhost:8090 ```bash $ echo '{ - "id": 3, + "id": 4, "jsonrpc": "2.0", "method": "dob_extract_image_from_fsuri", "params": [ diff --git a/src/client.rs b/src/client.rs index 64d5153..bbc9bcb 100644 --- a/src/client.rs +++ b/src/client.rs @@ -260,7 +260,7 @@ async fn parse_image_from_btcfs(url: Url, index: usize) -> Result, Error .ok_or(Error::InvalidBtcTransactionFormat( "vin is empty".to_string(), ))?; - let mut witness = vin + let witness = vin .get("inner_witnessscript_asm") .ok_or(Error::InvalidBtcTransactionFormat( "inner_witnessscript_asm not found".to_string(), @@ -273,18 +273,22 @@ async fn parse_image_from_btcfs(url: Url, index: usize) -> Result, Error // parse inscription body let mut images = vec![]; - let header = "OP_IF OP_PUSHBYTES_3 444f42 OP_PUSHBYTES_1 01 OP_PUSHBYTES_9 696d6167652f706e67 OP_0 OP_PUSHDATA2 "; + let mut witness_view = witness.as_str(); + const HEADER: &str = "OP_IF OP_PUSHBYTES_3 444f42 OP_PUSHBYTES_1 01 OP_PUSHBYTES_9 696d6167652f706e67 OP_0 OP_PUSHDATA2 "; while let (Some(start), Some(end)) = (witness.find("OP_IF"), witness.find("OP_ENDIF")) { - let inscription = &witness[start..end + "OP_ENDIF".len()]; - if !inscription.contains(header) { + if start >= end { return Err(Error::InvalidInscriptionFormat); } - let base_removed = inscription.replace(header, ""); + let inscription = &witness_view[start..end + "OP_ENDIF".len()]; + if !inscription.contains(HEADER) { + return Err(Error::InvalidInscriptionFormat); + } + let base_removed = inscription.replace(HEADER, ""); let hexed = regex_replace_all!(r#"\s?OP\_\w+\s?"#, &base_removed, ""); let image = hex::decode(hexed.as_bytes()).map_err(|_| Error::InvalidInscriptionContentHexFormat)?; images.push(image); - witness = witness[end + "OP_ENDIF".len()..].to_owned(); + witness_view = &witness_view[end + "OP_ENDIF".len()..]; } if images.is_empty() { return Err(Error::EmptyInscriptionContent); diff --git a/src/decoder/helpers.rs b/src/decoder/helpers.rs index 58394de..f7a6857 100644 --- a/src/decoder/helpers.rs +++ b/src/decoder/helpers.rs @@ -103,6 +103,7 @@ pub fn decode_spore_content(content: &[u8]) -> Result<(Value, String), Error> { return Ok((serde_json::Value::String(dna.clone()), dna)); } + // if content is not a valid JSON, treat it as a DNA string let value: Value = serde_json::from_slice(content) .unwrap_or(Value::String(String::from_utf8_lossy(content).to_string())); let dna = match &value { diff --git a/src/server.rs b/src/server.rs index e014585..a4b1775 100644 --- a/src/server.rs +++ b/src/server.rs @@ -155,7 +155,8 @@ impl DecoderRpcServer for DecoderStandaloneServer { let ServerDecodeResult { render_output, dob_content: _, - } = serde_json::from_str(&self.decode(hexed_spore_id).await?).unwrap(); + } = serde_json::from_str(&self.decode(hexed_spore_id).await?) + .map_err(|e| ErrorObjectOwned::owned(-1, e.to_string(), None::<()>))?; let svg = self.svg_extractor.extract_svg(render_output).await?; Ok(svg) } @@ -173,12 +174,12 @@ impl DecoderRpcServer for DecoderStandaloneServer { let image = raw_images.first().ok_or(Error::NoImageFound)?; match encode_type.as_deref() { - Some("hex") => Ok(hex::encode(image)), + Some("hex") | None => Ok(hex::encode(image)), Some("base64") => Ok(STANDARD.encode(image)), unknown => Err(ErrorObjectOwned::owned::( -1, format!( - "Unknown encode type: {}. Supported types: 'base64', 'hex'", + "Unknown encode type: {}. Supported types: 'base64', 'hex' (default)", unknown.unwrap_or("unknown") ), None, diff --git a/src/svg/mod.rs b/src/svg/mod.rs index 38a3a42..c782c48 100644 --- a/src/svg/mod.rs +++ b/src/svg/mod.rs @@ -18,7 +18,8 @@ pub mod puretext; const DOB0_TRAIT_NAME: &str = "prev.bg"; const DOB1_TRAIT_NAME: &str = "IMAGE"; -const DEFAULT_SIZE: u32 = 500; + +pub const DEFAULT_SIZE: u32 = 500; /// Detects the MIME type of an image from its hex-encoded content by examining file signatures. /// Returns Some(mime_type) if recognized, or None if not recognized. @@ -73,9 +74,7 @@ pub fn detect_image_mime_type(hex_content: String) -> Option<&'static str> { } // AVIF: ftyp....avif - if hex_content.to_ascii_lowercase().contains("66747970") - && hex_content.to_ascii_lowercase().contains("61766966") - { + if header.get(8..16) == Some("66747970") && header.contains("61766966") { return Some("image/avif"); } @@ -141,7 +140,7 @@ impl DOBSvgExtractor { }; let image_content_base64 = STANDARD.encode(&image_content); let svg_content = format!( - r#""# + r#""# ); Ok(Some(svg_content)) } else { @@ -186,8 +185,8 @@ impl DOBSvgExtractor { async fn replace_svg_fsurls(&self, mut svg_content: String) -> Result, Error> { // Create regex patterns to match btcfs:// and ipfs:// URLs in href attributes - let btcfs_pattern = regex!(r#"href='btcfs://([^']+)'"#); - let ipfs_pattern = regex!(r#"href='ipfs://([^']+)'"#); + let btcfs_pattern = regex!(r#"href=(?:'|")btcfs://([^'"]+)(?:'|")"#); + let ipfs_pattern = regex!(r#"href=(?:'|")ipfs://([^'"]+)(?:'|")"#); // Find all btcfs URLs let btcfs_urls: Vec = btcfs_pattern @@ -205,6 +204,7 @@ impl DOBSvgExtractor { let mut all_urls = btcfs_urls; all_urls.extend(ipfs_urls); + // Let upstream fallback to next process if no URLs are found if all_urls.is_empty() { return Ok(None); } @@ -219,7 +219,7 @@ impl DOBSvgExtractor { continue; }; let base64_content = STANDARD.encode(image_content); - let data_url = format!("data:{};base64,{}", mime_type, base64_content); + let data_url = format!("data:{};base64,{}", mime_type, base64_content,); // Replace the URL in the SVG let old_href = format!("href='{}'", url); diff --git a/src/svg/puretext/render.rs b/src/svg/puretext/render.rs index a5f24aa..68c02db 100644 --- a/src/svg/puretext/render.rs +++ b/src/svg/puretext/render.rs @@ -1,4 +1,7 @@ -use crate::svg::puretext::parsers::{ParsedStyleAlignment, TextItem, TextParserResult}; +use crate::svg::{ + puretext::parsers::{ParsedStyleAlignment, TextItem, TextParserResult}, + DEFAULT_SIZE, +}; #[derive(Debug, Clone)] pub struct RenderProps { @@ -107,7 +110,7 @@ fn convert_items_to_elements(items: &[TextItem]) -> Vec { } fn generate_svg(elements: &[RenderElement], bg_color: &str) -> String { - let width = 500; + let width = DEFAULT_SIZE; let padding_x = 20; let padding_y = 30; let line_height = 27; @@ -119,7 +122,7 @@ fn generate_svg(elements: &[RenderElement], bg_color: &str) -> String { // Calculate dynamic height based on content with minimum of 500 let calculated_height = elements.len() * line_height + padding_y + 10; - let height = std::cmp::max(calculated_height as u32, 500); + let height = std::cmp::max(calculated_height as u32, DEFAULT_SIZE); // First pass: collect used font weights for element in elements { From 232bb076bd7a2bd070bfc952a2b4e23b95f6ffad Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Tue, 9 Sep 2025 14:22:06 +0800 Subject: [PATCH 11/26] bug: replace witness to witness_view --- src/client.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/client.rs b/src/client.rs index bbc9bcb..67fe3af 100644 --- a/src/client.rs +++ b/src/client.rs @@ -275,7 +275,8 @@ async fn parse_image_from_btcfs(url: Url, index: usize) -> Result, Error let mut images = vec![]; let mut witness_view = witness.as_str(); const HEADER: &str = "OP_IF OP_PUSHBYTES_3 444f42 OP_PUSHBYTES_1 01 OP_PUSHBYTES_9 696d6167652f706e67 OP_0 OP_PUSHDATA2 "; - while let (Some(start), Some(end)) = (witness.find("OP_IF"), witness.find("OP_ENDIF")) { + while let (Some(start), Some(end)) = (witness_view.find("OP_IF"), witness_view.find("OP_ENDIF")) + { if start >= end { return Err(Error::InvalidInscriptionFormat); } From 9b21c08b2028a824a32367af5ce09c521b67b6ee Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Tue, 9 Sep 2025 15:19:02 +0800 Subject: [PATCH 12/26] feat: support decoding http url --- src/client.rs | 18 ++++++++++++++++++ src/svg/mod.rs | 5 ++++- src/tests/dob0/legacy_decoder.rs | 22 ++++++++++++++++++---- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/client.rs b/src/client.rs index 67fe3af..f36bd2b 100644 --- a/src/client.rs +++ b/src/client.rs @@ -199,6 +199,21 @@ impl ImageFetchClient { .boxed(), ); } + URI::Http(url) => { + requests.push( + async move { + let image = reqwest::get(url.clone()) + .await + .map_err(|e| Error::FetchFromIpfsError(e.to_string()))? + .bytes() + .await + .map_err(|e| Error::FetchFromIpfsError(e.to_string()))? + .to_vec(); + Ok(image) + } + .boxed(), + ); + } } } let mut images = vec![]; @@ -214,6 +229,7 @@ impl ImageFetchClient { enum URI { BTCFS(String, usize), IPFS(String), + Http(String), } impl TryFrom<&String> for URI { @@ -233,6 +249,8 @@ impl TryFrom<&String> for URI { } else if let Some(body) = uri.strip_prefix("ipfs://") { let hash = body.to_string(); Ok(URI::IPFS(hash)) + } else if uri.starts_with("http") { + Ok(URI::Http(uri.clone())) } else { Err(Error::InvalidOnchainFsuriFormat) } diff --git a/src/svg/mod.rs b/src/svg/mod.rs index c782c48..981f0e1 100644 --- a/src/svg/mod.rs +++ b/src/svg/mod.rs @@ -119,7 +119,10 @@ impl DOBSvgExtractor { if dob.name == DOB0_TRAIT_NAME { if let Some(dob_trait) = dob.traits.iter().find(|value| value.type_ == "String") { if let Value::String(fsurl) = &dob_trait.value { - if fsurl.starts_with("btcfs://") || fsurl.starts_with("ipfs://") { + if fsurl.starts_with("btcfs://") + || fsurl.starts_with("ipfs://") + || fsurl.starts_with("http") + { return Some(fsurl); } } diff --git a/src/tests/dob0/legacy_decoder.rs b/src/tests/dob0/legacy_decoder.rs index e7473b2..e2e7129 100644 --- a/src/tests/dob0/legacy_decoder.rs +++ b/src/tests/dob0/legacy_decoder.rs @@ -19,6 +19,8 @@ const MAINNET_NERVAPE_SPORE_ID: H256 = h256!("0xbbe57f0e7f7ca6e6c59007b28150e39c9c6f5c209493801cfc9ef125e0937ed4"); const UNICORN_SPORE_ID: H256 = h256!("0xe5bd5bbf82fec9107ba86fb65b3756915ca0d3a28d5e13a0aa82269b62a129ef"); +const MAINNET_WORLD3_SPORE_ID: H256 = + h256!("0xd1b01eda64c924ffe83a8d7d6511ceb56dcde9d52722e9d2df3f2db3c13f1fda"); fn generate_nervape_dob_ingredients(onchain_decoder: bool) -> (Value, ClusterDescriptionField) { let nervape_content = json!({ @@ -307,13 +309,12 @@ fn test_manual_render_output_to_svg() { println!("\nGenerated SVG:\n{}", svg); } -#[tokio::test] -async fn test_decode_mainnet_unicorn_to_svg() { - let settings = prepare_settings(SettingType::Mainnet, vec![]); +async fn decode_svg(network: SettingType, spore_id: H256) -> String { + let settings = prepare_settings(network, vec![]); let svg_extractor = DOBSvgExtractor::new(&settings.image_fetcher_url); let decoder = DOBDecoder::new(settings); let (_, dna, dob_metadata) = decoder - .fetch_decode_ingredients(UNICORN_SPORE_ID.into()) + .fetch_decode_ingredients(spore_id.into()) .await .expect("fetch"); let render_result = decoder @@ -321,5 +322,18 @@ async fn test_decode_mainnet_unicorn_to_svg() { .await .expect("decode"); let svg_content = svg_extractor.extract_svg(render_result).await.unwrap(); + svg_content +} + +#[tokio::test] +async fn test_decode_mainnet_unicorn_to_svg() { + let svg_content = decode_svg(SettingType::Mainnet, UNICORN_SPORE_ID).await; + println!("svg_content: {svg_content}"); +} + +#[tokio::test] +async fn test_decode_mainnet_world3_to_svg() { + let svg_content = decode_svg(SettingType::Mainnet, MAINNET_WORLD3_SPORE_ID).await; + std::fs::write("world3.svg", &svg_content).unwrap(); println!("svg_content: {svg_content}"); } From 17627161edeba8cb662aa437f5439ed28fc06c71 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Tue, 9 Sep 2025 19:33:38 +0800 Subject: [PATCH 13/26] chore: allow CORS --- Cargo.lock | 481 +++++++++++++++++++++++++++++++--------------------- Cargo.toml | 6 +- src/main.rs | 12 +- 3 files changed, 301 insertions(+), 198 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e1c6885..9e71f9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,7 +66,7 @@ checksum = "a507401cad91ec6a857ed5513a2073c82a9b9048762b885bb98655b306964681" dependencies = [ "proc-macro2", "quote", - "syn 2.0.57", + "syn 2.0.106", ] [[package]] @@ -90,12 +90,6 @@ dependencies = [ "rustc-demangle", ] -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.21.7" @@ -114,15 +108,6 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf9ff0bbfd639f15c74af777d81383cf53efb7c93613f6cab67c6c11e05bbf8b" -[[package]] -name = "beef" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" -dependencies = [ - "serde", -] - [[package]] name = "bit-vec" version = "0.6.3" @@ -157,15 +142,6 @@ dependencies = [ "cty", ] -[[package]] -name = "block-buffer" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" -dependencies = [ - "generic-array", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -202,7 +178,7 @@ version = "12.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "142316461ed3a3dfcba10417317472da5bfd0461e4d276bf7c07b330766d9490" dependencies = [ - "digest 0.10.7", + "digest", "either", "futures", "hex", @@ -217,7 +193,7 @@ dependencies = [ "sha2", "ssri", "tempfile", - "thiserror", + "thiserror 1.0.58", "tokio", "tokio-stream", "walkdir", @@ -305,7 +281,7 @@ dependencies = [ "lazy_static", "rand 0.7.3", "secp256k1", - "thiserror", + "thiserror 1.0.58", ] [[package]] @@ -328,7 +304,7 @@ dependencies = [ "anyhow", "ckb-occupied-capacity", "derive_more", - "thiserror", + "thiserror 1.0.58", ] [[package]] @@ -350,7 +326,7 @@ dependencies = [ "ckb_schemars", "faster-hex", "serde", - "thiserror", + "thiserror 1.0.58", ] [[package]] @@ -557,7 +533,7 @@ dependencies = [ "serde_json", "sha3", "sparse-merkle-tree", - "thiserror", + "thiserror 1.0.58", "tokio", "tokio-util", ] @@ -769,22 +745,13 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "digest" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" -dependencies = [ - "generic-array", -] - [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", + "block-buffer", "crypto-common", ] @@ -809,9 +776,11 @@ dependencies = [ "serde", "serde_json", "spore-types", - "thiserror", + "thiserror 1.0.58", "tokio", "toml 0.8.12", + "tower 0.5.2", + "tower-http", "tracing-subscriber", ] @@ -978,7 +947,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.57", + "syn 2.0.106", ] [[package]] @@ -1043,6 +1012,18 @@ dependencies = [ "wasi 0.11.0+wasi-snapshot-preview1", ] +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "r-efi", + "wasi 0.14.4+wasi-0.2.4", +] + [[package]] name = "gimli" version = "0.28.1" @@ -1144,15 +1125,9 @@ dependencies = [ [[package]] name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "hermit-abi" -version = "0.3.9" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hex" @@ -1218,9 +1193,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.8.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpdate" @@ -1245,7 +1220,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.6", "tokio", "tower-service", "tracing", @@ -1254,9 +1229,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.3.1" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe575dd17d0862a9a33781c8c4696a55c320909004a67a00fb286ba8b1bc496d" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" dependencies = [ "bytes", "futures-channel", @@ -1265,6 +1240,7 @@ dependencies = [ "http 1.1.0", "http-body 1.0.0", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1293,7 +1269,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.3.1", + "hyper 1.6.0", "hyper-util", "native-tls", "tokio", @@ -1312,11 +1288,11 @@ dependencies = [ "futures-util", "http 1.1.0", "http-body 1.0.0", - "hyper 1.3.1", + "hyper 1.6.0", "pin-project-lite", - "socket2", + "socket2 0.5.6", "tokio", - "tower", + "tower 0.4.13", "tower-service", "tracing", ] @@ -1386,6 +1362,17 @@ dependencies = [ "hashbrown 0.14.3", ] +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags 2.5.0", + "cfg-if 1.0.0", + "libc", +] + [[package]] name = "ipnet" version = "2.9.0" @@ -1424,9 +1411,9 @@ dependencies = [ [[package]] name = "jsonrpsee" -version = "0.22.3" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cdbb7cb6f3ba28f5b212dd250ab4483105efc3e381f5c8bb90340f14f0a2cc3" +checksum = "3f3f48dc3e6b8bd21e15436c1ddd0bc22a6a54e8ec46fedd6adf3425f396ec6a" dependencies = [ "jsonrpsee-core", "jsonrpsee-proc-macros", @@ -1438,48 +1425,54 @@ dependencies = [ [[package]] name = "jsonrpsee-core" -version = "0.22.3" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71962a1c49af43adf81d337e4ebc93f3c915faf6eccaa14d74e255107dfd7723" +checksum = "316c96719901f05d1137f19ba598b5fe9c9bc39f4335f67f6be8613921946480" dependencies = [ - "anyhow", "async-trait", - "beef", + "bytes", "futures-util", - "hyper 0.14.28", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", "jsonrpsee-types", "parking_lot", - "rand 0.8.5", + "pin-project", + "rand 0.9.2", "rustc-hash", "serde", "serde_json", - "thiserror", + "thiserror 2.0.16", "tokio", + "tower 0.5.2", "tracing", ] [[package]] name = "jsonrpsee-proc-macros" -version = "0.22.3" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7c2416c400c94b2e864603c51a5bbd5b103386da1f5e58cbf01e7bb3ef0833" +checksum = "2da3f8ab5ce1bb124b6d082e62dffe997578ceaf0aeb9f3174a214589dc00f07" dependencies = [ "heck", "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.57", + "syn 2.0.106", ] [[package]] name = "jsonrpsee-server" -version = "0.22.3" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4882e640e70c2553e3d9487e6f4dddd5fd11918f25e40fa45218f9fe29ed2152" +checksum = "4c51b7c290bb68ce3af2d029648148403863b982f138484a73f02a9dd52dbd7f" dependencies = [ "futures-util", - "http 0.2.12", - "hyper 0.14.28", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "hyper 1.6.0", + "hyper-util", "jsonrpsee-core", "jsonrpsee-types", "pin-project", @@ -1487,25 +1480,24 @@ dependencies = [ "serde", "serde_json", "soketto", - "thiserror", + "thiserror 2.0.16", "tokio", "tokio-stream", "tokio-util", - "tower", + "tower 0.5.2", "tracing", ] [[package]] name = "jsonrpsee-types" -version = "0.22.3" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e53c72de6cd2ad6ac1aa6e848206ef8b736f92ed02354959130373dfa5b3cbd" +checksum = "bc88ff4688e43cc3fa9883a8a95c6fa27aa2e76c96e610b737b6554d650d7fd5" dependencies = [ - "anyhow", - "beef", + "http 1.1.0", "serde", "serde_json", - "thiserror", + "thiserror 2.0.16", ] [[package]] @@ -1537,7 +1529,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.57", + "syn 2.0.106", ] [[package]] @@ -1548,9 +1540,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.153" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "linux-raw-sys" @@ -1624,7 +1616,7 @@ checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" dependencies = [ "miette-derive", "once_cell", - "thiserror", + "thiserror 1.0.58", "unicode-width", ] @@ -1636,7 +1628,7 @@ checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.57", + "syn 2.0.106", ] [[package]] @@ -1656,13 +1648,13 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.11" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -1713,16 +1705,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi", - "libc", -] - [[package]] name = "numext-constructor" version = "0.1.6" @@ -1754,7 +1736,7 @@ dependencies = [ "numext-constructor", "rand 0.7.3", "serde", - "thiserror", + "thiserror 1.0.58", ] [[package]] @@ -1784,12 +1766,6 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "openssl" version = "0.10.64" @@ -1813,7 +1789,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.57", + "syn 2.0.106", ] [[package]] @@ -1930,7 +1906,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.57", + "syn 2.0.106", ] [[package]] @@ -1998,9 +1974,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.79" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -2014,6 +1990,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.7.3" @@ -2039,6 +2021,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2059,6 +2051,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -2077,6 +2079,15 @@ dependencies = [ "getrandom 0.2.12", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -2187,7 +2198,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 0.1.2", "system-configuration", "tokio", "tokio-native-tls", @@ -2214,7 +2225,7 @@ dependencies = [ "http 1.1.0", "http-body 1.0.0", "http-body-util", - "hyper 1.3.1", + "hyper 1.6.0", "hyper-tls 0.6.0", "hyper-util", "ipnet", @@ -2229,7 +2240,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 0.1.2", "system-configuration", "tokio", "tokio-native-tls", @@ -2255,9 +2266,9 @@ checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" [[package]] name = "rustc-hash" -version = "1.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustc_version" @@ -2420,7 +2431,7 @@ checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.57", + "syn 2.0.106", ] [[package]] @@ -2436,11 +2447,12 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.115" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12dc5c46daa8e9fdf4f5e71b6cf9a53f2487da0e86e55808e2d35539666497dd" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" dependencies = [ "itoa", + "memchr", "ryu", "serde", ] @@ -2466,19 +2478,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha-1" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99cd6713db3cf16b6c84e06321e049a9b9f699826e16096d23bbcc44d15d51a6" -dependencies = [ - "block-buffer 0.9.0", - "cfg-if 1.0.0", - "cpufeatures", - "digest 0.9.0", - "opaque-debug", -] - [[package]] name = "sha-1" version = "0.10.1" @@ -2487,7 +2486,7 @@ checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" dependencies = [ "cfg-if 1.0.0", "cpufeatures", - "digest 0.10.7", + "digest", ] [[package]] @@ -2498,7 +2497,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if 1.0.0", "cpufeatures", - "digest 0.10.7", + "digest", ] [[package]] @@ -2509,7 +2508,7 @@ checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if 1.0.0", "cpufeatures", - "digest 0.10.7", + "digest", ] [[package]] @@ -2518,7 +2517,7 @@ version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" dependencies = [ - "digest 0.10.7", + "digest", "keccak", ] @@ -2571,20 +2570,30 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "soketto" -version = "0.7.1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d1c5305e39e09653383c2c7244f2f78b3bcae37cf50c64cb4789c9f5096ec2" +checksum = "2e859df029d160cb88608f5d7df7fb4753fd20fdfb4de5644f3d8b8440841721" dependencies = [ - "base64 0.13.1", + "base64 0.22.1", "bytes", "futures", - "http 0.2.12", + "http 1.1.0", "httparse", "log", "rand 0.8.5", - "sha-1 0.9.8", + "sha1", ] [[package]] @@ -2613,13 +2622,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7a2b3c2bc9693bcb40870c4e9b5bf0d79f9cb46273321bf855ec513e919082" dependencies = [ "base64 0.21.7", - "digest 0.10.7", + "digest", "hex", "miette", "serde", - "sha-1 0.10.1", + "sha-1", "sha2", - "thiserror", + "thiserror 1.0.58", "xxhash-rust", ] @@ -2636,9 +2645,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.57" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11a6ae1e52eb25aab8f3fb9fca13be982a373b8f1157ca14b897a825ba4a2d35" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -2651,6 +2660,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "system-configuration" version = "0.5.1" @@ -2690,7 +2705,16 @@ version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.58", +] + +[[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl 2.0.16", ] [[package]] @@ -2701,7 +2725,18 @@ checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.57", + "syn 2.0.106", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] @@ -2731,31 +2766,32 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.37.0" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", - "num_cpus", "pin-project-lite", "signal-hook-registry", - "socket2", + "slab", + "socket2 0.6.0", "tokio-macros", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] name = "tokio-macros" -version = "2.2.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.57", + "syn 2.0.106", ] [[package]] @@ -2865,17 +2901,45 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.5.0", + "bytes", + "http 1.1.0", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-service" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" @@ -2897,7 +2961,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.57", + "syn 2.0.106", ] [[package]] @@ -3038,6 +3102,15 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasi" +version = "0.14.4+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a5f4a424faf49c3c2c344f166f0662341d470ea185e939657aaff130f0ec4a" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.92" @@ -3059,7 +3132,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.57", + "syn 2.0.106", "wasm-bindgen-shared", ] @@ -3093,7 +3166,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.57", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3152,7 +3225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" dependencies = [ "windows-core 0.54.0", - "windows-targets 0.52.4", + "windows-targets 0.52.6", ] [[package]] @@ -3162,7 +3235,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" dependencies = [ "windows-result 0.1.0", - "windows-targets 0.52.4", + "windows-targets 0.52.6", ] [[package]] @@ -3186,7 +3259,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.57", + "syn 2.0.106", ] [[package]] @@ -3197,7 +3270,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.57", + "syn 2.0.106", ] [[package]] @@ -3212,7 +3285,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd19df78e5168dfb0aedc343d1d1b8d422ab2db6756d2dc3fef75035402a3f64" dependencies = [ - "windows-targets 0.52.4", + "windows-targets 0.52.6", ] [[package]] @@ -3248,7 +3321,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.4", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -3268,17 +3350,18 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.4", - "windows_aarch64_msvc 0.52.4", - "windows_i686_gnu 0.52.4", - "windows_i686_msvc 0.52.4", - "windows_x86_64_gnu 0.52.4", - "windows_x86_64_gnullvm 0.52.4", - "windows_x86_64_msvc 0.52.4", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -3289,9 +3372,9 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" @@ -3301,9 +3384,9 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" @@ -3313,9 +3396,15 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" @@ -3325,9 +3414,9 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" @@ -3337,9 +3426,9 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" @@ -3349,9 +3438,9 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" @@ -3361,9 +3450,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" @@ -3403,6 +3492,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "wit-bindgen" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36" + [[package]] name = "xxhash-rust" version = "0.8.10" diff --git a/Cargo.toml b/Cargo.toml index 66cf41b..4432601 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,12 +25,14 @@ ckb-vm = { version = "0.24", features = ["asm"] } spore-types = { git = "https://github.com/sporeprotocol/spore-contract", rev = "81315ca" } -jsonrpsee = { version = "0.22.3", features = ["server", "macros"], optional = true } +jsonrpsee = { version = "0.26", features = ["server", "macros"], optional = true } +tower-http = { version = "0.6", features = ["cors"], optional = true } +tower = { version = "0.5", optional = true } toml = { version = "0.8.2", optional = true } tokio = { version = "1.37", features = ["rt", "signal"], optional = true } tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"], optional = true } [features] default = ["standalone_server", "render_debug"] -standalone_server = ["jsonrpsee", "toml", "tokio", "tracing-subscriber"] +standalone_server = ["jsonrpsee", "toml", "tokio", "tracing-subscriber", "tower-http", "tower"] render_debug = [] diff --git a/src/main.rs b/src/main.rs index 36b5e21..0cbdbb3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,8 @@ use std::fs; -use jsonrpsee::{server::ServerBuilder, tracing}; +use jsonrpsee::{server::Server, tracing}; use server::DecoderRpcServer; +use tower_http::cors::{Any, CorsLayer}; use tracing_subscriber::EnvFilter; mod client; @@ -43,8 +44,13 @@ async fn main() { let decoder = decoder::DOBDecoder::new(settings); tracing::info!("running decoder server at {}", rpc_server_address); - let http_server = ServerBuilder::new() - .http_only() + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + let http_middleware = tower::ServiceBuilder::new().layer(cors); + let http_server = Server::builder() + .set_http_middleware(http_middleware) .build(rpc_server_address) .await .expect("build http_server"); From 18fbb9245436c0e79315b5091fc29296491e454e Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Tue, 9 Sep 2025 21:04:04 +0800 Subject: [PATCH 14/26] chore: apply bgcolor --- src/svg/mod.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/svg/mod.rs b/src/svg/mod.rs index 981f0e1..f996ec0 100644 --- a/src/svg/mod.rs +++ b/src/svg/mod.rs @@ -17,6 +17,7 @@ use crate::{ pub mod puretext; const DOB0_TRAIT_NAME: &str = "prev.bg"; +const DOB0_BGCOLOR_NAME: &str = "prev.bgcolor"; const DOB1_TRAIT_NAME: &str = "IMAGE"; pub const DEFAULT_SIZE: u32 = 500; @@ -130,6 +131,16 @@ impl DOBSvgExtractor { } None }); + let bgcolor = parsed_dob.iter().find_map(|dob| { + if dob.name == DOB0_BGCOLOR_NAME { + if let Some(dob_trait) = dob.traits.iter().find(|value| value.type_ == "String") { + if let Value::String(bgcolor) = &dob_trait.value { + return Some(bgcolor.as_str()); + } + } + } + None + }); if let Some(dob0_fsurl) = fsurl { let image_content = self .fetcher @@ -142,8 +153,9 @@ impl DOBSvgExtractor { return Ok(None); }; let image_content_base64 = STANDARD.encode(&image_content); + let bgcolor = bgcolor.unwrap_or("#000"); let svg_content = format!( - r#""# + r#""# ); Ok(Some(svg_content)) } else { From 0d97f11aa75e09491ac4cc4e8bf4e5dca222d0aa Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Tue, 9 Sep 2025 22:15:12 +0800 Subject: [PATCH 15/26] chore: adapt to client integration --- src/svg/mod.rs | 2 +- src/svg/puretext/render.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/svg/mod.rs b/src/svg/mod.rs index f996ec0..9416b63 100644 --- a/src/svg/mod.rs +++ b/src/svg/mod.rs @@ -155,7 +155,7 @@ impl DOBSvgExtractor { let image_content_base64 = STANDARD.encode(&image_content); let bgcolor = bgcolor.unwrap_or("#000"); let svg_content = format!( - r#""# + r#""# ); Ok(Some(svg_content)) } else { diff --git a/src/svg/puretext/render.rs b/src/svg/puretext/render.rs index 68c02db..dd0625e 100644 --- a/src/svg/puretext/render.rs +++ b/src/svg/puretext/render.rs @@ -198,7 +198,7 @@ fn generate_svg(elements: &[RenderElement], bg_color: &str) -> String { // Create the complete SVG format!( - r#"{}{}{}{}"#, + r#"{}{}{}{}"#, width, height, font_defs, gradient_defs, background_rect, svg_content ) } From 2ec141382aaf8ac3a8f864b5f0fc9b3fb339a30b Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Wed, 10 Sep 2025 10:02:22 +0800 Subject: [PATCH 16/26] chore: bypass https certificate verify --- Cargo.lock | 106 +++++++++++++++++++++++++++++++ Cargo.toml | 2 +- src/client.rs | 75 ++++++++++++++++++++-- src/svg/mod.rs | 2 +- src/tests/dob0/legacy_decoder.rs | 9 +++ src/types.rs | 2 + 6 files changed, 190 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e71f9b..1649f70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1248,6 +1248,23 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" +dependencies = [ + "futures-util", + "http 1.1.0", + "hyper 1.6.0", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -2226,6 +2243,7 @@ dependencies = [ "http-body 1.0.0", "http-body-util", "hyper 1.6.0", + "hyper-rustls", "hyper-tls 0.6.0", "hyper-util", "ipnet", @@ -2236,7 +2254,9 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "rustls", "rustls-pemfile 2.1.2", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", @@ -2244,14 +2264,31 @@ dependencies = [ "system-configuration", "tokio", "tokio-native-tls", + "tokio-rustls", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", "winreg 0.52.0", ] +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if 1.0.0", + "getrandom 0.2.12", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "route-recognizer" version = "0.3.1" @@ -2292,6 +2329,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -2317,6 +2368,17 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "beb461507cee2c2ff151784c52762cf4d9ff6a61f3e80968600ed24fa837fa54" +[[package]] +name = "rustls-webpki" +version = "0.102.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3bce581c0dd41bce533ce695a1437fa16a7ab5ac3ccfa99fe1a620a7885eabf" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "ryu" version = "1.0.17" @@ -2607,6 +2669,12 @@ dependencies = [ "cfg-if 0.1.10", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spore-types" version = "0.1.0" @@ -2632,6 +2700,12 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -2804,6 +2878,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.15" @@ -3042,6 +3127,12 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.0" @@ -3187,6 +3278,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd7c23921eeb1713a4e851530e9b9756e4fb0e89978582942612524cf09f01cd" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3503,3 +3603,9 @@ name = "xxhash-rust" version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "927da81e25be1e1a2901d59b81b37dd2efd1fc9c9345a55007f09bf5a2d3ee03" + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/Cargo.toml b/Cargo.toml index 4432601..3833680 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ ckb-hash = "0.116.1" thiserror = "1.0" serde_json = "1.0" hex = "0.4.3" -reqwest = { version = "0.12.4", features = ["json"] } +reqwest = { version = "0.12.4", features = ["json", "rustls-tls", "trust-dns"] } jsonrpc-core = "18.0" serde = { version = "1.0", features = ["serde_derive"] } futures = "0.3" diff --git a/src/client.rs b/src/client.rs index f36bd2b..adb186b 100644 --- a/src/client.rs +++ b/src/client.rs @@ -11,8 +11,9 @@ use ckb_sdk::rpc::ckb_indexer::{Cell, Order, Pagination, SearchKey, Tx}; use ckb_types::H256; use jsonrpc_core::futures::FutureExt; use lazy_regex::regex_replace_all; -use reqwest::{Client, Url}; +use reqwest::{Client, ClientBuilder, Url}; use serde_json::Value; +use std::time::Duration; use crate::types::Error; @@ -156,12 +157,21 @@ impl RpcClient { pub struct ImageFetchClient { base_url: HashMap, + client: Client, } impl ImageFetchClient { pub fn new(base_url: &HashMap) -> Self { + let client = ClientBuilder::new() + .timeout(Duration::from_secs(30)) // 30 seconds timeout + .connect_timeout(Duration::from_secs(10)) // 10 seconds connection timeout + .danger_accept_invalid_certs(true) // Bypass SSL certificate verification + .build() + .expect("Failed to create HTTP client"); + Self { base_url: base_url.clone(), + client, } } @@ -200,14 +210,68 @@ impl ImageFetchClient { ); } URI::Http(url) => { + let client = self.client.clone(); requests.push( async move { - let image = reqwest::get(url.clone()) + let response = client.get(url.clone()).send().await.map_err(|e| { + Error::FetchFromHttpError(format!("HTTP request failed: {}", e)) + })?; + + if !response.status().is_success() { + return Err(Error::FetchFromHttpError(format!( + "HTTP request failed with status: {}", + response.status() + ))); + } + + let image = response + .bytes() .await - .map_err(|e| Error::FetchFromIpfsError(e.to_string()))? + .map_err(|e| { + Error::FetchFromHttpError(format!( + "Failed to read response body: {}", + e + )) + })? + .to_vec(); + Ok(image) + } + .boxed(), + ); + } + URI::Https(url) => { + let client = self.client.clone(); + requests.push( + async move { + let response = client.get(url.clone()).send().await.map_err(|e| { + let error_msg = if e.is_connect() { + format!("HTTPS connection failed: {}", e) + } else if e.is_timeout() { + format!("HTTPS request timeout: {}", e) + } else if e.is_request() { + format!("HTTPS request error: {}", e) + } else { + format!("HTTPS error: {}", e) + }; + Error::FetchFromHttpError(error_msg) + })?; + + if !response.status().is_success() { + return Err(Error::FetchFromHttpError(format!( + "HTTPS request failed with status: {}", + response.status() + ))); + } + + let image = response .bytes() .await - .map_err(|e| Error::FetchFromIpfsError(e.to_string()))? + .map_err(|e| { + Error::FetchFromHttpError(format!( + "Failed to read HTTPS response body: {}", + e + )) + })? .to_vec(); Ok(image) } @@ -230,6 +294,7 @@ enum URI { BTCFS(String, usize), IPFS(String), Http(String), + Https(String), } impl TryFrom<&String> for URI { @@ -249,6 +314,8 @@ impl TryFrom<&String> for URI { } else if let Some(body) = uri.strip_prefix("ipfs://") { let hash = body.to_string(); Ok(URI::IPFS(hash)) + } else if uri.starts_with("https") { + Ok(URI::Https(uri.clone())) } else if uri.starts_with("http") { Ok(URI::Http(uri.clone())) } else { diff --git a/src/svg/mod.rs b/src/svg/mod.rs index 9416b63..f5dbe7e 100644 --- a/src/svg/mod.rs +++ b/src/svg/mod.rs @@ -155,7 +155,7 @@ impl DOBSvgExtractor { let image_content_base64 = STANDARD.encode(&image_content); let bgcolor = bgcolor.unwrap_or("#000"); let svg_content = format!( - r#""# + r#""# ); Ok(Some(svg_content)) } else { diff --git a/src/tests/dob0/legacy_decoder.rs b/src/tests/dob0/legacy_decoder.rs index e2e7129..220e35b 100644 --- a/src/tests/dob0/legacy_decoder.rs +++ b/src/tests/dob0/legacy_decoder.rs @@ -21,6 +21,8 @@ const UNICORN_SPORE_ID: H256 = h256!("0xe5bd5bbf82fec9107ba86fb65b3756915ca0d3a28d5e13a0aa82269b62a129ef"); const MAINNET_WORLD3_SPORE_ID: H256 = h256!("0xd1b01eda64c924ffe83a8d7d6511ceb56dcde9d52722e9d2df3f2db3c13f1fda"); +const TESTNET_UNICORN_PNG_SPORE_ID: H256 = + h256!("0xe6b003cdbb042eff3b56fdceee725b42f9397b9044b8323056f283161c828357"); fn generate_nervape_dob_ingredients(onchain_decoder: bool) -> (Value, ClusterDescriptionField) { let nervape_content = json!({ @@ -337,3 +339,10 @@ async fn test_decode_mainnet_world3_to_svg() { std::fs::write("world3.svg", &svg_content).unwrap(); println!("svg_content: {svg_content}"); } + +#[tokio::test] +async fn test_decode_testnet_unicorn_png_to_svg() { + let svg_content = decode_svg(SettingType::Testnet, TESTNET_UNICORN_PNG_SPORE_ID).await; + std::fs::write("unicorn_png.svg", &svg_content).unwrap(); + println!("svg_content: {svg_content}"); +} diff --git a/src/types.rs b/src/types.rs index ac69d87..3c2db3f 100644 --- a/src/types.rs +++ b/src/types.rs @@ -94,6 +94,8 @@ pub enum Error { FsuriNotFoundInConfig, #[error("IPFS Gateway responsed badly with error: {0}")] FetchFromIpfsError(String), + #[error("HTTP(S) responsed badly with error: {0}")] + FetchFromHttpError(String), #[error("No image found")] NoImageFound, #[error("DOB render output is not in format of DOB protocol")] From 2a0e322541ad1c4c7a288a90b65ab082f5adc6f5 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Thu, 11 Sep 2025 16:30:44 +0800 Subject: [PATCH 17/26] feat: achieve svg text to path transformation --- Cargo.lock | 95 +++++++++ Cargo.toml | 3 + settings.toml | 2 +- src/svg/puretext/font/mod.rs | 11 ++ src/svg/puretext/font/path.rs | 235 +++++++++++++++++++++++ src/svg/puretext/font/turretroad-400.ttf | Bin 0 -> 48272 bytes src/svg/puretext/font/turretroad-700.ttf | Bin 0 -> 48248 bytes src/svg/puretext/mod.rs | 1 + src/svg/puretext/render.rs | 82 ++------ 9 files changed, 360 insertions(+), 69 deletions(-) create mode 100644 src/svg/puretext/font/mod.rs create mode 100644 src/svg/puretext/font/path.rs create mode 100644 src/svg/puretext/font/turretroad-400.ttf create mode 100644 src/svg/puretext/font/turretroad-700.ttf diff --git a/Cargo.lock b/Cargo.lock index 1649f70..1871a15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,6 +58,12 @@ version = "1.0.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-trait" version = "0.1.79" @@ -772,16 +778,19 @@ dependencies = [ "jsonrpsee", "lazy-regex", "lazy_static", + "lyon", "reqwest 0.12.4", "serde", "serde_json", "spore-types", + "svg", "thiserror 1.0.58", "tokio", "toml 0.8.12", "tower 0.5.2", "tower-http", "tracing-subscriber", + "ttf-parser", ] [[package]] @@ -839,6 +848,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "euclid" +version = "0.22.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" +dependencies = [ + "num-traits", +] + [[package]] name = "faster-hex" version = "0.6.1" @@ -861,6 +879,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + [[package]] name = "fnv" version = "1.0.7" @@ -1561,6 +1585,12 @@ version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "linux-raw-sys" version = "0.4.13" @@ -1592,6 +1622,58 @@ dependencies = [ "hashbrown 0.12.3", ] +[[package]] +name = "lyon" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7f9cda98b5430809e63ca5197b06c7d191bf7e26dfc467d5a3f0290e2a74f" +dependencies = [ + "lyon_algorithms", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f13c9be19d257c7d37e70608ed858e8eab4b2afcea2e3c9a622e892acbf43c08" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_geom" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8af69edc087272df438b3ee436c4bb6d7c04aa8af665cfd398feae627dbd8570" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0047f508cd7a85ad6bad9518f68cce7b1bf6b943fb71f6da0ee3bc1e8cb75f25" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579d42360a4b09846eff2feef28f538696c7d6c7439bfa65874ff3cbe0951b2c" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + [[package]] name = "matchers" version = "0.1.0" @@ -1720,6 +1802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2706,6 +2789,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svg" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683eed9bd9a2b078f92f87d166db38292e8114ab16d4cf23787ad4eecd1bb6e5" + [[package]] name = "syn" version = "1.0.109" @@ -3094,6 +3183,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a" + [[package]] name = "typenum" version = "1.17.0" diff --git a/Cargo.toml b/Cargo.toml index 3833680..be5928f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,9 @@ ckb-hash = "0.116.1" thiserror = "1.0" serde_json = "1.0" hex = "0.4.3" +svg = "0.15" +ttf-parser = "0.24" +lyon = "1.0" reqwest = { version = "0.12.4", features = ["json", "rustls-tls", "trust-dns"] } jsonrpc-core = "18.0" serde = { version = "1.0", features = ["serde_derive"] } diff --git a/settings.toml b/settings.toml index 6ecf2ee..d3120ac 100644 --- a/settings.toml +++ b/settings.toml @@ -8,7 +8,7 @@ protocol_versions = [ ckb_rpc = "https://testnet.ckbapp.dev/" # connect to the image fetcher service -image_fetcher_url = { btcfs = "https://mempool.space/testnet/api/tx/", ipfs = "https://ipfs.io/ipfs/" } +image_fetcher_url = { btcfs = "https://mempool.space/api/tx/", ipfs = "https://ipfs.io/ipfs/" } # address that rpc server running at in case of standalone server mode rpc_server_address = "0.0.0.0:8090" diff --git a/src/svg/puretext/font/mod.rs b/src/svg/puretext/font/mod.rs new file mode 100644 index 0000000..dcbf3c7 --- /dev/null +++ b/src/svg/puretext/font/mod.rs @@ -0,0 +1,11 @@ +use lazy_static::lazy_static; +use ttf_parser::Face; + +pub mod path; + +lazy_static! { + pub static ref TURRETROAD_400_TTF: Face<'static> = + Face::parse(include_bytes!("turretroad-400.ttf"), 0).expect("parse turretroad-400.ttf"); + pub static ref TURRETROAD_700_TTF: Face<'static> = + Face::parse(include_bytes!("turretroad-700.ttf"), 0).expect("parse turretroad-700.ttf"); +} diff --git a/src/svg/puretext/font/path.rs b/src/svg/puretext/font/path.rs new file mode 100644 index 0000000..a6f076a --- /dev/null +++ b/src/svg/puretext/font/path.rs @@ -0,0 +1,235 @@ +use lazy_regex::regex; +use lyon::path::builder::NoAttributes; +use lyon::path::{BuilderImpl, Path}; +use ttf_parser::{Face, OutlineBuilder}; + +use crate::svg::puretext::font::{TURRETROAD_400_TTF, TURRETROAD_700_TTF}; + +pub fn pathify_svg_texts(svg_content: &str) -> String { + // Use regex to find and replace text elements + let text_regex = regex!(r#"]*>([^<]*)"#); + let mut result = svg_content.to_string(); + + // Find all text elements and replace them with paths + for cap in text_regex.captures_iter(svg_content) { + let full_match = cap.get(0).unwrap().as_str(); + let text_content = cap.get(1).unwrap().as_str(); + + // Extract attributes from the text element + let x = extract_attribute(full_match, "x").unwrap_or(0.0); + let y = extract_attribute(full_match, "y").unwrap_or(0.0); + let font_size = extract_attribute(full_match, "font-size").unwrap_or(16.0); + let font_weight = + extract_string_attribute(full_match, "font-weight").unwrap_or("400".to_string()); + let fill_color = + extract_string_attribute(full_match, "fill").unwrap_or("#000000".to_string()); + + // Convert text to paths + let paths = text_to_paths(text_content, x, y, font_size, &font_weight); + + // Replace the text element with path elements + let mut path_elements = String::new(); + for path_data in paths { + path_elements.push_str(&format!( + r#""#, + path_data, fill_color + )); + } + + result = result.replace(full_match, &path_elements); + } + + result +} + +fn extract_attribute(text: &str, attr_name: &str) -> Option { + // Look for attribute="value" pattern + let attr_pattern = format!(r#"{}=""#, attr_name); + if let Some(start) = text.find(&attr_pattern) { + // Find the start of the value (after the opening quote) + let value_start = start + attr_pattern.len(); + if let Some(end) = text[value_start..].find('"') { + let value = &text[value_start..value_start + end]; + return value.parse::().ok(); + } + } + None +} + +fn extract_string_attribute(text: &str, attr_name: &str) -> Option { + // Look for attribute="value" pattern + let attr_pattern = format!(r#"{}=""#, attr_name); + if let Some(start) = text.find(&attr_pattern) { + // Find the start of the value (after the opening quote) + let value_start = start + attr_pattern.len(); + if let Some(end) = text[value_start..].find('"') { + let value = &text[value_start..value_start + end]; + return Some(value.to_string()); + } + } + None +} + +fn text_to_paths(text: &str, x: f32, y: f32, font_size: f32, font_weight: &str) -> Vec { + let mut paths = Vec::new(); + let mut cursor_x = x; + + let font: &Face = if font_weight == "700" { + &TURRETROAD_700_TTF + } else { + &TURRETROAD_400_TTF + }; + + // Scale factor (TTF font units to SVG coordinates) + let scale = font_size / font.units_per_em() as f32; + + // Get font ascent for Y coordinate adjustment + let ascent = font.ascender() as f32 * scale; + + for char in text.chars() { + if let Some(glyph_id) = font.glyph_index(char) { + // Create independent path builder for each character + let mut builder = Path::builder(); + + // Build glyph outline + let mut outline_builder = LyonOutlineBuilder { + path_builder: &mut builder, + x_offset: cursor_x, + y_offset: y + ascent, // Use font ascent for proper baseline + scale, + }; + + // Get glyph outline + if font.outline_glyph(glyph_id, &mut outline_builder).is_some() { + // Complete path building + let path = builder.build(); + + // Convert to SVG path data + let path_data = path_to_svg_path(&path); + if !path_data.is_empty() { + paths.push(path_data); + } + } + + // Update cursor_x (glyph spacing) + let advance = font.glyph_hor_advance(glyph_id).unwrap_or(0) as f32 * scale; + cursor_x += advance; + } + } + + paths +} + +// Lyon path builder (for ttf-parser) +struct LyonOutlineBuilder<'a> { + path_builder: &'a mut NoAttributes, + x_offset: f32, + y_offset: f32, + scale: f32, +} + +impl OutlineBuilder for LyonOutlineBuilder<'_> { + fn move_to(&mut self, x: f32, y: f32) { + self.path_builder.begin(lyon::math::point( + x * self.scale + self.x_offset, + -y * self.scale + self.y_offset, // Flip Y axis to correct orientation + )); + } + + fn line_to(&mut self, x: f32, y: f32) { + self.path_builder.line_to(lyon::math::point( + x * self.scale + self.x_offset, + -y * self.scale + self.y_offset, + )); + } + + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + self.path_builder.quadratic_bezier_to( + lyon::math::point( + x1 * self.scale + self.x_offset, + -y1 * self.scale + self.y_offset, + ), + lyon::math::point( + x * self.scale + self.x_offset, + -y * self.scale + self.y_offset, + ), + ); + } + + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + self.path_builder.cubic_bezier_to( + lyon::math::point( + x1 * self.scale + self.x_offset, + -y1 * self.scale + self.y_offset, + ), + lyon::math::point( + x2 * self.scale + self.x_offset, + -y2 * self.scale + self.y_offset, + ), + lyon::math::point( + x * self.scale + self.x_offset, + -y * self.scale + self.y_offset, + ), + ); + } + + fn close(&mut self) { + self.path_builder.close(); + } +} + +// Convert Lyon path to SVG path data +fn path_to_svg_path(path: &Path) -> String { + let mut d = String::new(); + for event in path.iter() { + match event { + lyon::path::Event::Begin { at } => { + d.push_str(&format!("M{} {}", at.x, at.y)); + } + lyon::path::Event::Line { to, .. } => { + d.push_str(&format!("L{} {}", to.x, to.y)); + } + lyon::path::Event::Quadratic { ctrl, to, .. } => { + d.push_str(&format!("Q{} {} {} {}", ctrl.x, ctrl.y, to.x, to.y)); + } + lyon::path::Event::Cubic { + ctrl1, ctrl2, to, .. + } => { + d.push_str(&format!( + "C{} {} {} {} {} {}", + ctrl1.x, ctrl1.y, ctrl2.x, ctrl2.y, to.x, to.y + )); + } + lyon::path::Event::End { close, .. } => { + if close { + d.push('Z'); + } + } + } + } + d +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pathify_svg_texts() { + let svg_input = r#" + Hello + World + + "#; + + let svg_output = pathify_svg_texts(svg_input); + + // Should contain path elements instead of text elements + assert!(svg_output.contains("b)?WOC0WB$-SS$ia}4D+aWGU0q#W zRb4$Y&KNV}p<&UDb@dG~FUP#Xn6w?aw=}lQntT1szBd>v{D`rbCmZL^uNipxtWOv- z4&Z~+X3d>Y+?91s8e{wh)bH$G+tugUe{nry5znIhlkPJHtl3V33GJbnZC~EkyY{-$ z*yk8aM|q^Bw`)Tm@}u#6Dq=(LnzNRFJ7xcUjMW}wY|{rTdb*ay4BUw_z5?Z?E0Ccb z)ZT&jI=pABSUa%k-1OYFfbCw!bO+b0@9vuBUD1RzzL8tkc5UjD<;XWtKLhox>$=wV zOqekJTa;l8Qc2(X4Fktsy>}I3Nu3ze>c0M-zRo{YDgQ2!Ce8Tb2H6k^8jWaUiH zJWPwhSlLu|DQa93X#w6ODwpT@BN)?qhU)xYZ6f7_{=?Ul!`YNSJ+S?Qj;P8XSQNp) z*z4OTiMnr|o?nM=MoYq0kFKhGi2Mx@? zy88y1zUE$5G)`uDl)S%OlVOO_%O>TmS}M?`XvLeU=18Q+=+QiehQd%9QF8>OM1S=D zAfBvlFoRz_yVdKtxRu_Q)eYv*`vV51WU#unl)-GuddRSqagFEV?s=jn3h3YclQda| zIK3R1_W<`_;MH6@D6zW3+GRRcM~emj3~V_X<{Bf?UeJz`<}98Y)U?DR+0SZPW|KfY zN*xW$V%Ml?EvsRzYFfuKSc#gBM2cY!2JqL1Zztey1M6n}Y$e_YkS;)2$<`oVkKA6A z&teUjy?JaS-uqb(YRy4e7v?e_sUFnXi27Y9I}LCB;`{Y%9q6D4trX&~h*jW?`dp5B z8xgMu3@cG*0QouW3{kV3<&NmZ8tjKsq5Xum+YITv=JM7)3c!^N`ac;$eIYe`^IS{rG+r>U4`Qr-<>cKptU+MjOEX1{MFk=w|@A z8?izP@YQ;@7Fb^=pddW1L|Yq>Gk_X_8ORsC&>ZAqKC{GM7iwL=uEI)snEi#P@j_n1 zujL;~Ns>dVlCGBSl-`n4?vuC4d*$2Y2jr*ax8*N2)tYshyEKn#p3%IcHE5@4 zmud&J7i#xw4``2Q-`9of@^ve98+BLf?$kY^drB|sYxQU7FV2 zEHw-mE;Q^nd}EZ2ZN^>3Crk!Yt!cig*R;X(lkv)-TMqU#6o5;H&--!Gy@pqxMG~h&mD-8=VOJfej{1|J9O^$WO zR>!u)E{z?CeI|~@wZturTNk%A?oiy@s{|;__^_C z#$OWuoA}?wzmt%XFf*YwVO7Ez33nzulJH%EKQSzETw-pbJFzkG+Qhq)qLXGNU6%B6 zk}uhmoSK}Iyga!-d3*9_$v>vlq^wD~HRb-)u+)Uqsj2f)J5&2pH>d7My*Bm3)UU=R zjGH}f@wk=a&KY;nxTE7f8TWk}ON&WMOPiWDFKv0+rnK#8yVG7CA3nZf{JQbCj(^1( zW-Ya@vp#J7!j@rMW4p@sd3s6us`Ou{KWk64PqS~a|Iz+Mh9~2Kj4Ly4%(y4xk&I_D z-pu$o`NTR#DcJ ztl3#zS*K_HD(i}@8?y$p9?yC)>;0@BvLmuHvYWFvW#5|pMD|xXO*wbwJdyKBt~2+x z+@ra#<$jST=UMZr@;dT1=k3qCA@8BQSMt7f7#$WzzGJSV+i{-bddK6A*BqZaqntVT zJKy===P!yio=U%6<_FzbA3`0Q!=5Xz2wZ2YfBz1`OwYX`$KU@Ce#Qcf#CiYF-HSzX|&rke%l4Vl;r0tUqPI`LM#}!gVb;at6ODb-w zI9l;q#d{T>ReWFJt29)`RHjyDRTfrGs;sMQu57J59Xk$VCiaQc$E3Q3ne*7wYq|#3 zf%hI{`YG-8R(4J8oOxDu@r)_$R(3|yteIA}Y|gAE@Pc_$knfo{XNDE)nL#$ufvcNY zGP7f6b*VXeNK{GKCv(ADlsY=>or%l_4pF4$Xwh2&ct$q3k5Wg2JvN?=$9kWj=Ey9Z zSy&p&4E80lFc!zgf$QgsI$Dgx#9~=0b3zWnKR`s{h>^vx6y{(h-Q8>Z_|{+gT()lg zT7LE~eJ=0s>gIjF^tonbZx>(ni=XSe*YIVKH`nkb;@K*mbHsC|cs7WqXWhoN{e03d zeFBFZSfr;;JPE5L3(=EgHZJhZAtzGWAfAzULY%;?K@vqS?Yr!8Zx5$?R%&6J#Ljc|W@mG7-(SQa4$|R7)phs9PaR zNvH+MnOz}Zmq1Y_e91u*oG1xTlKDw${)tnX%2E+xLd&?s!^Au1@!|`mKb`+L-Z(E0 z$%{k#!_ezMq15)5?4N3T>X)EQf{I`ZK(7-y8a5Xa&7asaD!i#Gyo8fAY8z32lD=w3 zpm8vv6>wmM=0^kRV=7isWN@4$3n{fA&my-U@1H@!>v=3s;dY+Q z9lVH_@oGMmH}QqMm#^lV`5q}nDwF;!y(4R6gB&i$$T@O3_VqjDBl2Tu7o>e>jk6|O z)2!*%ENh{4lC{p-XFbb$(3WoNuw7!iI$cWFrbng6q+8OH)6>!&=~L4?(|a~7C?s#u0WAE-pSo`j>cNZhBdw1TuGvD>R zJMK6}|JTD>(l{9AgRyIQ2LD#lNRb$8vXm;NOBqsuR4LU+tFLl?=`r<9f)qCkGe3`?&bI*TlXwN@pq)Fp z2Q*R6^D!q~n2Szc%r}FYB0=$SVi!rriq63d7GcLJ2Q^J*)7T8i@hxmV)<-ua$CZ#$ z*Mp`e^2vM!uj98se%vgyvWvkL_ON||UTTAjkr=eO|-_%iUeDE?b$ZCiN>zkx@v zPx(xq$`koG?gCHKfcC?|E5ks0G1$?Pusfw<56i`_>A)UW&1#{FO<|3!hA)CnvV<*U zi`ZhenoVba28SGg9&s)^lbr+YavQshUBY&#=HYz#e-wIQ1>ivu=aFeLFPn-$4t$11squt7LaWPq~X# zv3poOyPwsu`@oSNVomH(XfcmNi+POAW`6*u`x7*tKZ4&q!xpfop;tY{I@!z64_;** z>?P<>uYi}m3BLC(bcH_lAvo9H*y-#eXeUpwHufyPkzdcR<2Uo$`S180{8qk;U&=4% zSMt653ceE>^B#T~>=i4ZUH7mzpyj*;J>z{o4J*5zH-gJF@Qe8++zral#kx-h7fR#f zxs}^^I`~m0xIs4dssw%(-@rHWGx<6ETz)nm;Aiknd@VRsH(x5%!uD|tj1Q|!I*aA} zFG2R9-WFCY4YIjz;>w1pX7+J3K11tauEt!_ z`&5xm;y17~DGhZI_|qeUw1^dJ7|QNGMffA~|4YzJLY>pqa13$(sPJ3BJvwYe-so@( zzWZU)b|>cODujI^jOX3z9PWcey^oC-bGQ$D@EExGEug^{QBEOSgiN08e^I0#22Dkx z?PRn$R>(lve+lx_sN=@>O32~&ppVe-PnN?+1)_0&A@X-JhlnYSASL||Aq?{vadO~4 z;9HcP6fDRK4Pn6PwV?BLfB|@uzCj@Rp}q|W+X2f=#1|n$cq$z4%G3vYl z{x`%j`B9b#UZ2F*AUuln`7DKBrsi!&-v8J2D*g9Mt68Qd7hxaEl)pfHE=!bON1Vr! z5Hd9nvP8{!NcXZN`AfWSM)|+gx*p`8hw?(?eZ?}h9*l!%=|1rE^RZ|Cf!X8v5_*0;nSj4{q4iK=5RAC>na|`k;XyX%n_X*1O;=5J^@Dq79Vvp#fgh!!n zf(XUf8|(;y^^(I+7wwjFGwNQ8H1SSaCkqj-L--Eue#zqaGf1C}H0D8~P=NGvYFf?X zk0Fl&=#%)Ds7LfM5y6DKD-aWH&qJElB{WaeCw^xabwCetANbsH@g*YQD0^Uq>z=zWAblyxJ-349aIe?h?a2Ox8cfBxSCwlV(UWB?NZ z`6`1w#U)4^OW9pq<{HRRcS9b?gse5h{A?N5LKgUx>$o1{>fr{+N>6hmdj>Len2=+B z;^B~c9MJKc>{-ZB&q33B9#T;>{e&%j!N2x$hN73@g}c*vG}54LNWU_HEkJ z9gsvS*+00GJV<%Z*w4lodXH(Y{(zepedaPnR6~Q#CdE!^j~7xw#9{VJu{33naY+mI$dcndDBs6f(>*-UG?D7t--cXgjO<8pt;5_?`&)BwZ_HR~sbQUqe>C68xqMd*T#mrF+0* zuEAQomF?&IAb~#tJ$668nqR|z1IzJmAwgcxZ-BgPhje@~(1waeBIbai+4tQ*kPb$6izQUCg`0bRX# zGt~#n_4Vqf^~$IEjq6qx78Mtp8iQ3dQ!+#priIpXmFi}8b#EN#(ajVMD_^^6 zbj?a`b4YH9TdrT%8!>Ce#&x}2{TtV=>DoA;pQW^_o1@g96H?z@s+%ivMWe+<<(heb zLpLv^N@=lLHYH;Ih-T-jz|L2I)z0rME?Bm*r@v>z z$_=^&z5QKh^n|qy7wXzXmabiVW@-;2MAxNYy-V$-OT~KkaE)aGN?nhLOg+H@eUJK0 zPbeCTU8de(70n6^)3~yLENifV>Q|~?tPJ_0*j1*Yx=go5LEV}Vytrz0>y+GeA-N@` zat|h8{Rm90S6bJ>tVq+?A5!04rrRKLRU}W;4A2A&gj6YYsbw`08%H#|Q3ZOVIsqHS z1e__F3p;Zd$Hp^*6L6+70cQ;t>dsOo;B4`k>FgkiM5nHaB}zPHq7v8C8qe=&iRup%MNo1KeU_q@@l07SXR}3f>*R50= zRQe!Or|3f@s1Hi0eNa;ALrGB|q8{}j>M4DQq|%3|DEgojjiP^L>{jcSs`O;``zvcY}B@Di6FX?G(9{c8c6eJ4J4#og%l=PLW$_ zr^p@nZi>>5yU++M3;0)r3+yWC4Pk^;H61R7s=lb-6{)d0o+)+oURXG#P*cCLf4wLK zw1%FweFJB0=owH-Yux&-{{Ho6ZtPROo>*oQ@BK7KYF4>XWG!2N<~lWJqLQgWv6x7VL8siYHGc)wbstgnn5Y9wq`I>uJuZ_^EO$%;dUfy zyOw!1Et?KWl7wcwww_cQ*Vd{PQmo6Y-p5+JnymIiIXt4azPsM5t8cY=<;=D@3tLe+Wk;*k+tPw8Pg{!B zTTZF+wl?daQX3HEAWMC-dJCz%kZL~G(rN`Zc63?2rk2)DNRN9QL2clUw)p$V5mbSGm>w;^9qpeLHL7R0M#$&H( z%lB%X)_SW~li7ta>1$hBy?T3%*I=)~4B(T_e6LQR8jbnW=OJSx ztF5oevjqt!%sG;f!j)FI10d95=$+R39riAoRDo=kLKEV(rU1wQl$ct3SDn&D#3?@a zX5cG)92{EcN0CJT_L{>HIJBw96s6d0ZFwN#DCeLg)q9t9)#ZDmofv}E>W!+MP9sAC zGvtk?w>fx=7V~9BL(u{$R^Xr;fP2lgoz@+lR<9Xo%lF1OXUuCI)GVuO%kW0@*f-^S zW1TZ*x6YWWWTx1VA1m_XoP#W;c7E$%OiZnpchz{!4q9>`t(w6odPL*l0 zHkcrwt7Zpg7~Mwa+3ff%kOnoYNh9EIMDEDRI>i)1)r++Q$+ccqeTZ`| z=)vNIN+8wGYxTz1YpnI&NRVHI9V7=*j6+T*V7NaqkwdJGWi>T5go!u+g0jIlgTuSi zk&+HNkH=tfOzll@4swbUfq06OoP#pO$<9Fy#VO7~EybzMK^?{8oP&Cb)0~3_ipM(# zjTAeaK#MXFUR@_pZnqYA`C?i&`Cey8VSKRgbfqvqq%bR3*sl~?oy;5MIMsk@tiM;r zN&^iYpAF-)0`M{yR$M{zdBM{y3uM{zF3M{yp;M{$AES}9i4 z1Se3O*l7j#Zv}UxS#*V<=Tvn{oL0Ah+>O?hcIr>&7hpqULQDB9{$8m5aQv#a zmk*Y33yrlD_`m?h{H7DDrmH;PTjnfCsLc14|F^23@NQI}h$&+6nbrbpBk=~HcG`{| zjrKfQF60-q2N^Vn+`R7$+jp3)&l_697WIS%*c8 z$v&8 z#p*zr7>t5xFPqm|V6DW?PS8tACK?S+g;$q}w`nK}3Ngwgo=kb2c6GL}1>}1t2YM6O zSEs9!B%RSi4otBJ{G))d*x*g5ZEZ=x=4!2MD;O-~aagxgMwHG;X&F&kGom!`ZD@O= zzo>P3CpkhZ1U?FMSm*RsICg;ih{kqcrJo8mOlX0(5FplzArl#ADVXnqq*kL0oygga zl~#b2rwpjUIcUPBL)0%Q|Cf<;<5&dtf1zT+H}SDbdwGg2gkEfIDx8gAz>^#StW3pQ zg~LX3MtD(26GXu@punOmdx$Am;jsnYQmpdnr^ugy=6GDJw+wY=I=vGSH4zr;fo^L9 zcH;oHnw><@-X@@Vmh%u}4M?;g!6`A@d5DXgIY@|{xm2eSMf0c*CFWBdN}NV@D6znK z7`(6+saB-0$s^U~JPgwyrP`5F@)lA(PI-%{o=7dGdLp%i>WNeb^;wTP@;$GP+~dNp+v8Ms}89Z0xn9e6mU^$m4J&M|3`mgY1 zwbI@;B}o;|SCUj=JL*mjws(Pe6YX87B&qsEN|LHyj4vlE?OmcIslpB=NfmZF4}}R4 z*Q-l8q>LTzRhT>R-(Y)aUB8t6A1s@kA36zSSX?k&;C#65imQ6_XluG;&X5 zBjU+5J?c zYhY6^h3%fRm!uEyJP+RXoINL=mx^b*^ateJCZ2~Ry1j6P$X`NF|1A3V7K6VM`uY58utDIo00KySp42wx*#xj#TNDq4@s2z!oc!i&-3 z3t>mX?hErWCgKBXe9Nf!>qforSKkBQ?NQ%%A-*tdYuMR{2N2e&@d`Cwig*#Az&}xr z+a+Nw2-Cyr!m1IMhZT!=XUKb2m^CaJWfaG$apb6XgZhp!ht@NlP~Y*r3AiwQt;U}r z{wU=AIMQz*ykvSd7(Zcp%!D~KQH*&s-EF$vg!waFYuabpt>$C?0_8|;H*Hqm={uaV znfejnxg_G{h&xT~CW_~&aWmqnYJLskN>iz+5OE#?#Tjaxh8U-H!TdmZ4D!NFIukSg zOtJC%QSq0f-ak>_KTzV}cW)biHoj(j!T7ZC=dcTbv$GKp8~>=rN0EL&$v0w~Fdjsp z_%=1Z0r53rTA6V#(o}vK!o|jIBjR)Meg@S;+^3X>Ut?Sqj&>+EE*lju8TH<(zRywO z;CIM3&O~TXW6!8~QpkG=(gg?<=ZuQeL*7%7jz^$4T8&L=tic5mpW!FNw`%;k8vlLN z`+K9_-%{TL-@T%~KZp29!!g4V#P=b1)%buK--7r$!khZOAF&T%k71XHFH~cC-)cD9 zFn}_O*Qqhq--!36>ieQm`KPJx^nDA$bTzJ1<7zc72VVgW4aK;XkcHT4NH)Y7A`MuF zBVq~kiFKntff(yX|D#f_|5~kwa+GY>e>R-2{|Fbpjw8H*@Djqa2u~n9rp6B;zE^*@ zp5oip_$I{Hs`>j6@7C`i_#!Z$6zjLE@n)nq4d(~T`;pfWF)5-X!b7os&8T?!sP|6w zyI2aUKH28ETw{IFWFII0k{r!x40Pru!M&!}q!` z>0S2;-ainvnC@+*Jfa{XM;T8->LQVUvB$=4$N1pF{K`=j({Jny6W z><=R6n8+cqo3j(*t0D2#5JAO;s27}wlfN}+8P9)+I-iTu&qe7!MCm^S3^CGPzz`!K ziJ?-YX%w*{9U)31s0AJ=Mja_y_!fRjoP8|nI7BadMK81EE08~1z6Q^QqRwnlA6P}s zr=rfMqW4cl&J2;BA<|7E-6YcY5Ik%;VH=WBAD)LqJ2S*{x~Mq|vLWhFeO@hD@th~! zNl($jKSfT2$QdVQEtMoGo+|x@p40-j`5`6oc&S%BY1HvjKb{HF@5S>`%Hi+hc_sA) zk9O)SmBz>$q!v6=1O{vZW9c*tJY9U7F44C(@vV)%l8#YYo=oik4r(U_{yX%1LOiMW z6nI3^^Hw}lm3;mQ(%*{SQ^Z$A@(kn@3mA$C&%8*$P$WvL91`$6E^-!#oCTugC8EtGVg{Fpua<~5h5iA&6KoTyzI2snAHG+}sS=p4 z5;dy?Y*hlPDgk-5k}tk06JJ%~D+#(3-3g{!7*_bQ(4F9MjP3+G;D2!unXZrgsnm^R5YD__-M)Su%g?} z$>=Fk)d8`B2q!p1gm%d1(5O*^?j=W|hb0KD2y@gh6LABA2Lbm+cnLxQLJmSYnn^{# z-5Gd*DcaV4w5_4`;g7@N2{sjPEdmD8EAxcT#e~*{xfguxbwGwReFN_?;&fk#If#Mx z4{}6X5y8xZLB@Lq(=5H3cbU69t$8Gwzr@+yR72;d6v zy5jKI;=Ut%HJ;zn)1Sz`NBR0b0_up7`kr3&0-09w-Vng3J& zH|Ul5e*&};{G5&=ra*5cqV8_!vN}=s1G-Csno50&@IAui{;haECq5mye6zfbV(8|)=Mojryby&V*~ zAG3EZ>Ms+sL^D1Namc+7_z?aE_!tBvPqKI5f5%xNydODt0%IC>!2b<*_+Q4`i@d=9 z0dk)7e~O%EkaGk%cVQM#6RB?i>lMJwJN}o^#;vdjNT~NIN`H^Oe*@3RYq2h_g9Sx{ z{I`&Q6@AYqP<_08itkYiskiX`HK-fGWPBTimJ<>7``=(!qwa4|^8oT+z_&^}w_wNm z0BzkS+QJ;7g+l)-b|c_&`maOD%P6@Gu+R5j%(_v!3h+<$KZAbW!I*TS_I|W`Bi;_6 zEhowgP*1`aRGU(C*N#H8z`cy|SiucoiGu&9kybAI7v==gg8w6WVvZzM?B6PxCGZy9 zW=Z#qM;%G#kUKz;@&YEyaG*namf38M*65NQTASIXN07`u8-LgB+sQX>l@p7HW)ct0 z!GokKBQR6yaYdL6T8&l%XGkr2)v{h(9g{kkBxN<}WQpTi2E_ZQ$nY?HV2m-F^@b#e z&26*T&9Sanvn<C&4XqWP9;K64Y1P49tbwdUW);T*^=L&G#qe`8lBkK&eb2wjhY4o~iS<*_6H@t~6vPi>br@rN_=5_@q|un)5FF{1QY6;1 z80i(kk%Git#7Gmc=g#uXOfhP3O%Pj*Mvje?w0e0aW>#aAHN7S{DC@OG{R$8f>wp2D zWi>IaR@Y(RI$gG|IWZCU-p83^#9ypA%rM>&3tDoQ*(^4_1rJLC9@#W{K0(s!b3UB= zNH?GBd$N(2_?~U#<-WVReN#&Kx7xcV-{2Iq_UZmD?!|{o?^#s3Wz#>oBV%aNzq)8e z6ob}DRxw~li(^%uNs&ga22kq&bzGD|55iaA)&g#T%R8{-1hllG;CkI`jx{HkqjiQ< zhYbrV!J<#F$k|AhnZ;Ae@`dy7i@LhpR$mYAYH#;l&wpc;j%-|d@r7Od&(nQhZ}Giw zj_-$QAbRY!Ou7WnL@|daCn`J){mXi69axr3B?)vd79YqWHa6C*F(f;9woPwNu$3j~ zZQ15BTY|Kt!FOeSJzvw{Sm5jE?Bq8skn$EU_N`d7*w(*=AKlVVvo87)>kZPUSZ@!B zS?3|^O{sGQW?iYX8#gmv!K|asE6S`B4^I;?k7q5Org5oBiIzC7*XzvDz@x;X!i_qh z6F8*ZQIfZUxV0UaY~WC@XB`?$es&XxT_D!MX;5)7F>x^lLz*KqK@Y;V=(DrkE=xi} znHwZ8MyOXtEG>2{xK39-Y59bLwDqTyOrOT5WhUftXI4dy7`jy; zA~^mT!LjY+_d@~^W4ky&(L@~=U_P{vC38HPVLCw@n2upWP*|%VggEe4Oot!?vqA$> zrZ0kj*I8QcTg-2+myT@qdH7w6hVEI2h6U}BzHEd{G0{_MLaUls;snx6Ee8cc(dWc$ z12a$fQfbbNrGY?5;$|>%yW0iYBZ9LK=}Dh=`XYLJ`FDBsz9qcH=jAumOVvKlX5QxH zWmhcn`JBFY77`VR5ke+U2lW#b?3RpThQW1}8CL5MoDwYhM5XwKeLG{ z5++&9F%DgNL8Agv%&|tJ?a*QUx)|Vhhp31cog{menm9>rE^zu}h4a*$d(WfZNS?xp}?sVd;qPQ9cEClO)_VWm3Pu zSpvy$M!lppfgOjD_!b6vIZOhr=yX|4GROzCkq{3r0OBd)UvxOOTy94%)Dv(8Y}pW8 zdaPKff4Uo;PM=QQPT%4i;Jc)>p$`NAY0iH9bKb0Q3Dye;WR~P1yH-{c2BQdz$dk>o zA;ICo2#WFZU;r7IwxQOUVvQ3;K>~y}KGxbSQtH5fr^k(!?lJ2m!E%z$a@wiR4y=z| zw8kaxF2UK+R|fjpAZAACYbW%G5i>IeJiA82qwsJZ59(Hv6Sv}+QXi^fp7dW%Z zqancnG?`>}%ySu$RQQ)KHP?U z4NINjXcnzf6ET`hIa^Syi-s@b!SDHeA-edy(edV+4mf6TLA*zKjobHz?{&A}`2IT4 zZ#pzoNIzIpYY4ky_cf5Bo|VoZ#0fcNe9J_qSV<9E5J!R3g9Go(xF2ua5e_ul*mFF%L+V> z7&8-XY)Cw)tT<|dHYZE5a0CFLIpQ6o&3kJa*x{_83v6)pohE|xV#m(Vww(O z$yrT$og}CeJqx{pII@9+SaY@=Dw|!82`jV3#@Z6h^40Z6&nZ3U(K>er*Q{Q}wH=aU z(a>+D?nOhdf=3U1A;oS6sVMzRWCx9)odC)qdZnEpNF*fYg)p@U7J*)46sD$j2@)Y1 z{OIOiHaHbm5|**QLtML+3~rDtrPnC!H&14wgbObBew)Y7a*pl53PYyP>a) zF>?Z1QAbeNg8-KH+p|T!nU#4;!i|y!GY0X7YhWMYT&59_at7Ikbr4FG`7<*y77KA` z$Yj~rV?u@`ojV~TYjt2y(vcHGlT%8{LdT{`Ie?n9ECuQ-rF@}fVPruqi$=c(IMzd& za(Z%wmH<>60N~hiX>44qDwm2akR(%~E=Vq4XIB@$p~GG8`!%+|)%BbCW1AHTb&+rR z!k~P(N8oz0ifct)3f0O5L?Lx9m*T`qI=K$XR7yKLB>hPm_vm(Zsj}J0?U1gjv~yWN zyARP-m3By`QtIr*UG$M!IrKjNptMIe0f!dcy=?H*MuC&0#9N|`1{hyRvVsUTGl5HH zE=w~aBP2c@(+&a{OY*lQCV4`Ahp5R}&Y?FKO0yS{x}fkYKuolujOt3s zb&>{-k!xZ=8y0Bbb)G573Fb)1NBWfb2oqQ}^a`SH+MggN>J`B}tH}sHB!i6$b}%kA z)*J&t0PMg-GQhC5ttyu!v&&FziG-vpgZg%3y+Ta&jiiTfu2U#V$`dO0!i7Xx3m3Y5 zMt@QWEH#*}f+DQgRABg^EfkB3WD+Ra*+ReOulshp z`QMA&<9+>nZabgidz6&xR#L2~7yk)q8jKLCEh{_>8l13YVVz%qpaD1Rg@OXrR4Y-R z*sx6|QZe{WZbzrGblb+IH~*cdY-vQFv{@JX z+oavn!{CAz=Jpg@V$6{d;YI_tbUqX21c~^_@ZgAxU@s;vf|Y6!L$ukU^n!tmTChd8 zv{~z)z?!|T!wn2W#a~^wtkzL7bu)hy3wH`XxNxDbchMphyVHT)FkDOkFE)pVnKU{r zu&bjDQz!OrO$S(5Py!_U0>gT2*W4a!mq%gQd3oh7-}fc_@5L}=^z->`yw>*!1?k9! z?$&9(Z?>p3I$dxAP@3S(`^Mx1(*gt6;$x#DG+JFkxTM!68znBM7_|7!B(8x@pwsKL`mNaW$O^g@yi(?Rxfeo;I%X{6 zz}6cZ10^9QCYp3<8+Kk;8eBBb*)TUj#&T!dvHv2)-}SwHrMv&c#;Lw*R_)^%SGqTx z7?{d?R&QzbbagGSYCo`f^W2KAu1(eL`vbE7MuCeK6&D9jk}t$~1TK_1yD>Q4Ci6^Gz^DJ zdZWRpH{g&7_C}2sC}BoDH}(RZrVeH@VH-0TB&g^Ro5%(yli|^UgEoc^9l1G~8Dxx! zi35rvh#v&eF@|Mk6zU|8ilBY%AvT(8M&OYDL&ezUQKp&@oPw@$Ko5k2beu~m^l&gp z59GNRPWCb*SQz$aEHQ{!iX@291-56%&ERT6>EtBaWJB6bD1#(=b-lY`@R^2&=b&{$ zo^dR4gHaLQL2J}@9CLXJqv2Hwk3!gMK}jkHC4;42=un_0nB#=ClGcz>FiuFi=!SHs zKv#A?@Az@|TW=}d-77inclsXSQ=RuKUD7zIPeBj!1V+)!Ih-gS-{uAA?y{FOjN;)jq=12Hd2wR5Q(Qg0Kg03&g&J(H1uqRZ`Qv z^)bd_V_TdUhIGWeXy|X6>=A}G1&Qjs5v=JrQ&Q$_hnP3op-DRpV;LS>n6hk1*Ab@G z;iU<4cTjSH2oIC0%PhtEHg~ui`1Hn-^Q6f`ue7&I&Y{Ou-qSDE8_u%@@44rs^(Oia zsdG7a@faGW)Q<&_4li43X9s>sht38@S$|N6q!6XfF8un=*zFA0xeULOE$WPtRQjPk z2HM#xX~(esvpKj)Bus>$IpU3A=G|Is-C}kFhGF0>-5g6MX)Sa^5F+*x zn0W#y)Y~O9G{rKq@!X-}5`t_~LTYwfGY`S)c>$R;bgX-D<}zX&>ROpQVm@ghs`H7t zrqd#f9%i%2G6WSD9YWC`b-2d&{i@ZzA6nfr??1cj zocm|GrP!e_@Kbh6e7E~vl9mqP0FSr&Zc@3o{6{gv%M@yo=AJae0=|$smrJ*wq7G4$ z(#{Te2aTqt(e3OS-It2t(}9avvgSmG;dp{Hd6hLn7b$uraTySxD$~$JgA2w80^|v?*cN0vHc}T9BBXiA_-EaJi(ED# z`g=DoGVm#Lmin&w-t{sV=L2mf#^ z%#Ubi*XX{~u}>1Tt(*WrK2uR^{~Msg#c2+*Y{NWn+$je3}Avc4Gu1c7tF$WMNd=!7uHNv|~XQBuvQs z9dO%N28(@cbW}J_boCH};Uy0)qS0_Hf6jAB0AL+ZCRGw5!4+SHK_Q?+-AuQt1K_Yh zhFAonN*RB?-1kjqCyy>GDVg?e8J3IJe{`;sM=>h+J zr)XyvKNURdgYE3)&-o9l@EjIEE2q1YfZI51rLfM^@mGY&i1RNxzKOvhFD&%Y!0n6? zhr6U~0o!`Lf_Pz@Pf1RQM>BCiHd*I`N4l*tm-la+4-o_yACGc|<|1i-v4^IJ@ zI)C$&v#w=@cl79|g3uP9Lh*8UeGkY=WBxZx&ve?sZ;Ra21#<=9f zm?*u;U`vYxap{;o%m9HF^0+CS>zPU48^)mpk^MxY$%WntO6ZWGP%AdWtW0~lb$n_H zT1|pXPX9%a?U3YgI@$ya3F5*+vU#S8i!vKYrIM-stH~L&eP33T1ko~y$9DS0D|A`G z`zpVx|6o&zJ8z03FMuJ3qxr5)3V!bBa=BH_iM;Y?wZQ&Et0l|?Z5dLfSS`ZZsUB*m zD+cRGQQyF81E&p;{#-Bvz4?n)DoSFg(QX(M@kTI7K zCxFl@r@@K}Lu-|ouh;j*>eawXY2`Qd_1{#MO3l2kdDfhX#U)VgT;;RHj!!Vazcig~ zQP(Q{&Z{^hlLY}_9SS(%i5jT0o4*V{CL_+#9`OGpD$!Wd#8~WXwP!{8cxWz0mIi4L zTUSbQ6ciOX2|66qQq4^A2!N6fRSj5{_-3ZXZ~JO>TQMd>2Q(B6kt}aG?~)gLS3magv4y{-4!^T)ST!+SA~3_Ng2-q|3KeU!6jTjkh&Y3S zp`H9}|3P*12Lqe|TvsDKDClUir}8AOD@i#tZ({FI>FAW4*A}3Bl44Y<=g*b56zlV1b$xCS>k}4~;QFLl*NFA0)Y;9S6zh}rQn5bAXyZZ+6}Om-b0igY=>5ZQfDV$FIEHfC00YQ4)GMF&My2ev%>5n=0ng7jC_ox zBqt`Iop?ye2@sJ}0y9F}tk9Vh^P448Y%j{x&^}9g)5l_leC;873J%RFDuaKvT{UR38bmnNDM3RTBaX6`&87<<-*7o0pze z(YTG@;akA(aCenDWwT=VzY=~MntKhgIcm_WqD($e}bi31&< zlH)G9K9`V?n>TcYq$?@*eN$Xa^H1`83aP$S8zmD?mNLy98=)*&`) zVGg1bFR}<>yLKg*`NQ>3Omn;4pReR+lr;Hnksk7WUe@Hp?Fl+Dl*-YEg-+LEaR90z zEf=a7wk4Qd=r|PW0O#tCg`G@n$Yc_tQ!Zf^5|$_GO=U(FyQRz}m6uYdjg9Vu2i>3Z zD}3wKzDu30t)i2`fX%5-0s9l z1SU}CjC@##nUYgYt~kz*)WL2I z0iZt~H?&uyFC*E$j5-K(aQ#z_iZq+#?ukvAW>6ED!ECa8O)FlMHq9kv*VUc)Dz8Xd zJ9G}_k)geFF^^gn>xqU{SjO1`W4Tzz93Ll$566w0=K3u71LVS$FiL~lhx-WVmR1n+gCFWy)-OA|-@m+cdXr+AB5g90+7a6G zt%7bYWp|C%p8YS1I;1@-b#{Y1Mr+T2Q@|tY5S)yB_Z4pPTfiAhUVpT2!v`7~06g@D zc}ieoPgJ}oGHJ^gb!^Y4HLd8eJ&Wb5(cTVdr= z$N8<0Xh8SS$E0xV42Xyn6bt%)Wtw1LV46o7o@^*Bo%Hur9NtL2OZnUR`Axn)q6BpF zEijpmdkI7dm%;}wz#YEDeLs;8?f_ps+$vJ^hac7bfT)O|3w*nez9p#r=ZSnl!SG0t z3}AQ!wK$~55BUxQugG5D^zrKXEEa^y?EyIdwjw8Ag`Ds971n0B`E>#KhoRgXgz`#$ z+3SlZ%uJv%BfJ&F@p@F`=wtN} zh_WOk29DM3!XF05p)TPN!(*{y^K2Pf^@)|oBe23W?W^7n!y1!)|9a5dz*kg0gy(MG zk>(_CWOy_*!wQD3Uhm5oI z7Y4aAXyH%=Ez$?Hf=m;zw~+M7PtRKq}5UU1>No0E& zwrbq%`@XWIWD3`JlvIB-yWy*9r|(DU;mRtqYUH?@zbjU5r)-xZK)0vr9kLp(!r?HU zob0v07%6#>-QeRM>^+k#KqvS96%3D+JUq~QN%re<`3J(L66~F1Pgx!NSep1K`==N? zR5|4sbd(KL3l|S@DnG&oTIu_~ql4>eN=jybIC=7y4ad332_d+-s&c4an%GQ6P-29F z8`Ax3oZx!wFpiO3b`-k}ax7s<=EKY?f%j(ZtuHOD@RsmhsV-dEIX+=RHqkIOeUab_ zbOV!k>{PK*m3-o{$QL~Jw`1~Hf=JY%9gx0Nb|wBie;YIpufM2B=-^sSYz&+yKqKUH zD{*p{7&e1p7=maZc$jf5LA8RpZ8%BCwx~FIC%ABm-VeGctgJfT*2dkw;}a*oy9mF` zT;8&BR&nuU*WBe|O#*=D#dwaX<7rbkkI1L-AYY8)qCd&HGjoj75xB%nk5BYZ(vHDx<+&>peBGFf)@8jmx@l zx}gB)G}uaTIZe_DokUCw6ch4Xg4RHc2ntEOIi5UR;^FrIH4_|%j(u@pAzWMno-X3z zN|Em%FSO#`ikt6i$0Zi^?#daZ(;Cl#zww!>-;0oB_!8(x`2P-gvasQ)4$O-8AFv91 z3QxySU*>><3%iWvZ78>;+xP+BY5YLRJMWYb9pc^>??Rix77GiI0rzsq+ndg_Nx_mk zOhybCt*aasHE{}~{uB1V;5q!T%Ei03p|3N~EIs>de!N8V*Xf)++u2EbDSBKYUkfUG zQl+vD>|MqBDHK(Lil~f6n!@IKT3}+3HPKNpt8p3U@1$hH1`%*QAwZ1=vfsjbsnr5d zVZu}oxuI|aEh#L800wEro8X0l3q>cL!3SV}J38bDK32uo@=@pS3i^axP$%FNV-XY- zjqa40`Zyr>nIQku1_c5UN45Zwpu;!<6GqB1{*k@|OC-!9 z_}%5c%VFL=-Dg*^NWb2Yj=QFzavxzgG@tHY1lIh=LR&4@|_f8qZ{0R>F-noZM*Quy`SUo7+5Q9m5;R4^jS1)LuBM}<*N@xlex2*Tz3=c3KDw6-OiF)4 zd;j5NU9l>Rd9rTL4vdga)`e5Xs^1q(`29JhT^lOg6E}31y!YNwZuNb26(8?=|0-_w zy{C*#`kSDL*Hw!6C@@PS@-N5#2}Z>Q@=10@eWD1-ITl5X_;wdZn<{<(MM!MWYobhA zNH(E|<+xEa0(dsRiT4R6@5zLe95AU#Ozmu*oBm^eEcS-5kemHp`h^p-$T3R}h&&Vk43sCYG z0{#zF_*VpGm46}hKI8+=-TaY}d$_s^z7SR+rOr;i>?Es@4PVopqjdPd0zde_iEI?@ zBVQT_$-kBIY0t#}AEXlbB!R+qZ^A7qiH>B*TMp;lT6rcS++4!Zq>wHzvW+7pDp&*SHegdWdM^e6P}xyrZIGM>5Y^o}Ruu3uk-ZS}cwS92U!r z)Z}xL$@)W3&~KZNt_VL4^4$jSge2kTDl>-BC zVb1qfRP3GPw6C(`KYlklbUg6el$2kk;OATvS>aLaR8UOFM~)na;Fv?Q0R1LToVbJ_ zrq{xobvm6vXNWOt^ofo#?t<@cCVmKp7ZmY=p$*b}-?zSRT++oun_Mobe&}JM9Fj!H zk4t!>M?ei1UMXl@T=AsVfj|fy3^r3Zg@8?(b^26+K9}$z#_GcHht$7iMx*b(h8bHs z_%=9oJzmUzh9|48Sekqut!DpyoOY`y@}?E{zG%vD_e(h9$1A6w=!jWp=Sn048kwy` ze|kN6M~q42gy!{;(?eX8ZihikI;R_+7s93-OrC?;;IAIgMMSQo#Q! z?|pv4x3i;@Z|HJ2_#Ufo;FBB9pD_y%wy2$08HEXC=ZB^<(t7e7~n4`(JS;q~b zTw{b+OsvJMsPS6i7L!R5CJqxJa^eti3x9s-1m82g&Ud)MaT@X(m88Z^Zbb=uIuNw-QaRf zn%6w*m8yzY1Ni+$&_8UcaA3!|{B$e<&?QzeZh;LOeH4;3n=Q%}zD(d2l%JqCiz7)m zMU}iU-!}Qil1D!O{BwS3ZElJ0*ua1|L!!O9a~Ru^xJ5+v6LAeI#6vzXM6)G$+oKE~ zlkV(Hx)W^CLiO_dww&gu=QA6o`d0HFz_x$2xG#Ot!lvF1p5%V8{i4%BpAMkUC`N?; zvxR4}B5TN&XdKsLROEqx1)+gEF%$PqF+#yTfMuO72m%CI)eA4vCQdgMFKXad_1V9} zx2=O)R+Thkh{NCG^Hr>Kd>u7jvg zH>gzV1!l2_4)Ya~ zZ~U^_cR!Bvwv_swsjkNViFMZO^5Wtbnm?H}v`3ogx=+B9iAnLo5A7FQJKdj-#WPz`ZhuxtCqxUibx*pZH|oKAupN|6x&j6_2QU ze|JXy+N-AKS5HZv+YTc-Zgt|Z5>n~^iFb_>?t{DwB7k~JA;`O6S^?4V%*SUq3U0}n zJv;ZN362?$*STC}Q*zU7%d0%gZMHm5S^1vaEc{fS0#`f#8|dm6a;~xf=dviA>-D_7 z)yd8kdFNk${q@{Zl%Dx=ON*0wQ1JXw0MBFaEewyu17!h^;9GP7BNO|m!ncFXi!rWo6abHruwcTQ9dgOd_cm^+rH)Dt;ws zYnWfzh+h#sk!g$-oK-MboId%lwR~z(l_zQ5!bb2ag@Qi(Kk_Q{7qI6NlML`G8%dEu zepP<2L=j~XpFl;j-9F58=xdqsKq6`&#BVYBt*=AN6K z17R045P z4+5_`J~=Rk;W8>bY3%<$UPwy_#S6~k@An_&yX0}$(R7FPGB0-M%Q)TL)K8niEp4gO zeV?|bx}`ti7-^_tGSQI#sPr)Er{k=hH1l{qL1>=H*O-I(b|oL{_h+2;lSWMC=ZSI& z-%za`2OGO_lfwvTa?4iLwql+{p{Zo=vDpxPh)(4Lj67o-zSQ4 z9I%M*w~78wK(Y?NHx~RuA^qPlM*l-<|J44nG1?ze+o$qoQ4VvO=zopqe+aiG0`R{o z$_cN-`16JI-y_QPYB?;2z@JaTnpDUC-WcEe)$f7-vG5B)U6~((FI#*+0zW8M@I&}l z#y6sWlq>yHd4l-fsKVDz@S*(fAbexYFXY%DeQ1^W2_3(k{fo*m&YxshP_V<0mv9f= zAZ3c@PRQvZ@|54zAXndF7oR#6b?yFZ*mkxB767vcH#bxdkuw7RA*eYmtH8B9vt-vz zByZwZ3>M<|zsS!BI%9~R5pH)Z0JE0iuZCNdvrgq~g!Xb=E*I~cHqG}30Dv~_{?)M3 zJcTxmq*JRtFA!fZ#CSLpKN3oMHRG_fgt)%YD6lo+xy03Et8n!!TlRCFd*VF829#<1 zqUu}$XBa`M!9D`@+<@O1!#!6z zEm~&1khlp0<2|VX5k=^c0p~#|{lXB2-zu1Q-!|(HcK~+G%Kzc!grcq5msG6s%VpJz-Ii@JNUA9(2GN zUSmtEClm9GUu*2A>ag&T;u%l_0=E?LvuY_gl@ax{TmN&#HcwP^baZSqY)&{!5hp}k z+~9#&rmF57n3FQ^DE&i2y>EX#H22?j_>PtG`dyFrG%Z?W?K`i}S2TaVfZczVv>m^_ zDFgNaPgtz@RWtat2Vln4SGMSa5`O7H04@6Q{0{JcKv z!i7!CAHQ@5FbR8|k@fI4z@FwI88bkvI4%*yiUMW9#S$U8xUr>$zJkU21N^?Lp4mKU zq_GM=K?0gDP|1n2S+UU)ew+k9Xhv2<%OCp3kMAG zw>IV*CI%RBpa6?s4h)(Zupq?m1&YI@1eOpNXO4>#zeACr7uP1}rvhC-0HDOyhHC)! zxmhixE!lGi2IgiXku`UKe@V{)k)Jhp<3>sl<@q0!-j}V+&a&AiPh?86B~GK6ZiJqU zTkJEui6FjIlR=WgjbJPi>y8N1%S3#D4R<_b&|C&3MK<*6UaAYKSU@ojnbgl_wRw^= zG8oIq&dAQnL}zwbN?wl$X^yzR~*6jP~)3 zwtd$n{QgJ={E@=X8lH}^@%g^)}xles06ncOEc`DYR!WO4%% zA(4wlh(ZWhwn)*SptwR|wgk6)P5x zuC~}!^Upr-_y02i!F4wznaS{f_xF3h_xgO^`*s&Sp-p%Fx%j`%ch;!?A%Ce4s^{>> z>+%==cOkj=8J%NY=fAV&d{<5M+|niIVzQOh%I}Sm z*;a0a^#e3h$Yn#|qTC!Ug z*e&8ND#AlU+qd_M*VQkN>wCpXBh-dc#mPeS=%Q_wFSi-NS3x64YHTEDjHQJr>16~Z znLqR2qUf9J5Jj4s-sL*s@buewIzT-iVaHJqQk8k+74*4s zMjTN>)C2gMxzHneQXh%A%H)FzJuBuyQQ{;X`342R0q6_mPk5d&5KEITP8oqwOxTrP zFhQkC=`WNsjGR(XFyhqlJF1p9RNmC=X{Z!O?x-(|Z|7fn+n;d^vC(E9qQPH#4B+EV|p`yu^>>jyC))?s~0asi_c>Bfy#@nj30959}QsFG=k2J|M# zs9T@?lgcSW8iDT@W=Iy4B#4<_RAn7c|DwOAzeT$Q!v0BP)=Y4=b%1$CQExuzMiHglS}EJZdO;}OT8KC zYRi^Qk86Kf)YF6Sews1R)ip2>k7+w%YCjD&Yo8%ngxAq5Vu(9*q1S!MS;(6cgp9&s z2Djr>Cs=f0o`-ZaXEG2`%+%*X{55202!_tZ%;lxhN z+KhZCj+)ynCw~%UapFgoWo0%wojROu)!ba*ePx!RraBx7R{DMAZn+%r=(1BTFON@I zeL1ie%x76Fxo}xvpOQ8hO9=csL2s{*-!`V+ykg%C4`kkHhey`U-a(Ukw~wr&?(WTt zI+s!Q(B4fwJ(~vN%e7AyJuGa?*R-ugwA={qcJ^$%3hm0vnLJ)MTnUQdk`;5{;INS% z4~V;#mMU3UI#3ZUb;1EBty3BnEEM-Vd zeB%y!dm#`2aFj1@9LPqoQ8a!ZH@=ws6wI`Kmenk|PV>=vsRDdtWGNR;wkQt55-B!4 z2?ambNXi5MYG541gBXPj^%9D{;IQt9v2fJQh3lk`;fMsV|eeJ^&4`U>=Tk%fgzc_yDV8&Gu&Pxj*Z7E z8eQVm0E{x+^A_P0r8v1*4};U#G2eEDT^74kYxR&J&Vbub3lJa#4M_CQ8!&x+ANM_(E@?2q7LYsH*uG+Jw3cq*n zUcGBq)$UzYyLJV4?`BCTtUM;1bP(@s#yBuC5FG+!6*=b!TCkW0G3V^>G+P+s9Xpzx zoQ(6ph@Bk7^6~unuh41D^UD1BbTA&nKglnTq3-)+-T!}WAJ%!*Ul`_E=`<})Bz7he z`mA4%TK}l;lDu4DVdymK10rDsVXaiG4 zItJ5%{Pg|WS)aoU?7(Z5#&(dqLcfz2#T;BKCH*h-Qpk%kU z0smrbPcgWK=UR{hspC&M8xd1X3Kmj1Ff(O!?5U5V#gV1$7+MV>|w&sN@ zZrW7BbRM3D7a+}sWZ&GNHJy5CEB6o=qi`^yl|w3uYh}kmwhCAfD7<88*FBLt>HOVy zA3qK`Kb?aiaQBMNM;;J$h_+-@I*AUbqYJR7o4wx<>L&r$k|UuY3L&t9BcTwn3Zu** z2d>IUK*U19hr|#Sd3G!C*<~ak+b`IJ!Eg-hT1NDo9uReIPdK!vqhohCyeFx>>s{yb zt@FO`;TO*XUT^GZ%;$?8{YkNFhpYI9@;8q^b)06IiZM}N)xBwWN!hI7C8f;bqm5PK z)o6p9d4>KP^OdC_E0W_|pdo=)BtK-VQ3w@gHY;Wb(q@$bK@*`m+tk(S-SlhmgYjGR zpGX9?L{o>bUm5WR7!Wd3p4Aa3iIN)A3^-#Gm0B-10~An1R$H)rU67#Icg2aj(4oPD z@0b36%O~w?*S3GsHSp(Y)6e&-u@?ug`jwt7Tmj-_q+Mwxg(M4QTG@=gu*|v=OK4Ej zF!rD6O3CCc7)$~&m;lwS2*uV8E&R}Ax;62KmgkiG0;2Xy~&h4 zoQWHFf8cdQ3yuGX=EZ(4HRW*X`O8Oe;nXrhOxY1=KWNN^n^f{QuGH%4&XAOc1N zmj!Eg&@@BRP_EPjLdsaO9&uxWTnvS361h$son^(sW)4+V1fbV=-NItE&XIfonGG;( z)x)qALX-;vz-*j~BBNZ89f@VobBWnz$@drl*uD&V9xM^(12Q5mDKYWfB3M5;xq3=L zu>|f??fLf!Td8*CElgy zrcp^KpGurtp)e=F{7m9J`bxjM#B4Xm)o4>geNDC9VGsJsi_OTC0E1Baz!NY{UQAO1 zn1z$g3NL!ATNq-xk>Hj17Pf9ev>f+#- zTo`@fNCMs#HvU%0J6sEcUxf^<=>bqqT{Ig%V^+6xja0j!Lw7Rdk@KVP?f+brE_ZsLiM1dTo6`js#py zpBx2)SdsT;JOXl!fNXy^(PXde#6uvk2!^*% zu)LO~lqXAJ_u%2+(j;Tiu@BZmP{aJjfG~%83qv+n;#A_cTM`@YzjopNUXvP5C%y%VBF-mRwR?2($>3HJ2Qw$_k$sIaDwek#Lqw!I=omLAmc7PLl)h zRmcFhIB^&oVMBmXhiF8@cGx7~arAgRK~FH?=R7gUNC?)0StTLE-jG(14j4xsJ>Lz) z@M%yN4DJ#g7T)3EJvFb-o%=>8(c9gv74`HSdz%(EH6N_2Gl01_9rAU2l&r1O!b7y@ zTEjM@Pk;aR%_${&+oSz7esyzfXjD4@!_LU9QS_IsKW6lo zS2bUv8s@?}#~8&j;Rfan$Rid#;5u^?R(5uFel`p#ASlBv$s$%hh$$aS&yp4`J+`(9 z`piwLdeb|Bd35WVk#*zUd?Nxqc4k2jM~j6OKi!Cy{Ytg+Ofn`xs4UjfT;K@jklj{Y z6%2SLWy7TDC6mL)$imQk1li`4lid%umAK-HCrfdJHNzKqG0w09Y}#RB%^cr+k5}3Y zn@%mX<1C9_Qp~0>M(;{&`^y%$_Ag52ghMVwM**y6vII7Q=#ABYw7|kxdWj6MViq4J z0#K>;NOwH2%U`>%r@p@DaCh9lD^UGVUtL{1_`~YJePMq*URGAN*q_)D@b5?@;%>Kl z$d|a+@4uI3x_v`sK7+RImur;^=iC~pnJmw;%bQr-m_LY66~1z)Bo1Un!_8-gmGTnf zFl=7Kl`KFbX?FP6$qVn!_;OC|3sj7$LR}1T{{wWCdp;a#Y8ymjlKa)1zgw zOk>&eO-nkK{R2n)`;X3wRqd^>-&+;?3bMU#>Y1rir}}-qr+hwtD>ocfo~F-?5tb<< za)g7psg3tV;bdVomlO$`6_| z?W{0_<=J!XK+*uvP>CE>fzB5EK^>651tA`CpG+pHi1tzp;S>Lglc(X!#zuc6)LZNJ zMj{cMOx??tX)i2O+bDoQ*^_P5U0CP~xr+CL;1aaZR0SViHY}=hlD%wTC})stCqST? zGdpl3ILw=~utMhV;rN#3mqGakE(NC@+yH)QuLq`K0Px|9H(It8m#K@66MuX99yFFQ~mm zL43kHkv7m3VxN>o6LQY$F22tIC(30F^Tw41Qvdym_)NAvJw1E6qyCM4eA9;5i?P^? zkCm3*TgrDRa&!kWSh3>3OTl|7sd7fF;l#?Xhyp2xFQnmf0^tad!T#@&ASf*(L5{#s zqn+bZIjp=zkE6XTC6ufL@0F$>wxNLNCVci686R0SRb3$JL?M~tKqmXKC`ZzCXEUu{ zO}LX(*7;M^S-|-)5QqUk#IAKdv%svRg$7zXD<^QAp`puU#HJ0fXa$E5e#nw|6kq&{ z=8TTUb~TTVHtzy*fc6J5mO3Gh%2@#A!e{AYSqIV$UfAR;(@GEzbipk*a$Qg{dl0lg z3=HfV5Jwvu8xd{y-Arw1|L0c)CAkn>fe96C8Q_vAcblj|FZd?5QrU-GHpelLKFn1W$|G0EakA8-<%z|zFIh^n>`S%7io=bAH}w2p0m~SlUmWbk(t%J0m=$o| zpX?rsG5X09i`M>CZC!rXqOuo}!U@iM_N4Nl4N0{W`z3UOLNdpR&K5SZ7Ilhc%wBW7G*QgVGsrE{7S-S$P1*|mKz{w+I-U>3%W#>34*dUtIUvNkHKfFN{i z!r+V!v9%>*QiRJKD=hC@gm(H5K>_fd zZ{!v{`{VD>hgtyz>bPt-*-(~!tLd^|RZ}N84K&s0_j5jDy- zK7)Xv_6y9t{@G&XWBJ*A7;pNs=|EOHgy-(1<>}9WJV01u6FrErrJtK75!ejuHKV>U zps#^zGwxdExiMvn{Olg(smy2TzWm^VzSJmuzoUt^Q)%Y8oirdn8zZn0y$#@|9mKlx zo#ww!ej_V zF^~YE7gKUJ%{UGaI;0RA@i2(U`kA$LQ7=cmB80LHxwq6eEL^;|F5`Y7@_#EtWPAPMCDj{WKIPvvKo9TeH%Hh`Lq?)7`7r_4Yhm_1b2n4MM2RYkD@XS1sXxMtPJG zuC+aD`g5kv%ELE|K}lS{?(~h1zk2_vLQI&2F`c%4pnrY$6Ca*}@91CrN=PAxhC~En zA@369!YsVPh{3qTEO9AH{5;$VyeafuZ5#>{!nk0tX2@quq@3VCs7*Oi%=mD|%iUcO zQ@$1v1cMNN+E&SB|GXjBhPq+KMD<3LGixYA0fSPm97C@{*{L++eXbVEPEjb6sRau? zv_IxRg5m`W8y1KgMA-L=2Ag`b_{Jr~74%IWN1H;pgi3G!_D`JPzf@knEyU36>diy8 z(Ql$1f7?VG3SSfv z11Q6giU7P)q>9OS7Kk!DD=-gI%oEG->=P^TJXQPz&-2ALJa>q_cSG`!`b+N*<0)nyN|P5aYW8_AZl?FXnTN9I{fI?q8DeHj!l6vcI%?S>q``Y}Zxffq z2m13+r6I!-ZC1mxkI13zKEtJl6fyHYW1mUPq{V_i7O@-+OM?|@pET)d3H*Mcrxld> zNKdPxSlq9t4Z!?B0 z#A?Lrkh=ok7m7N}UX$2__W{w5QjPf5gSpH_FY8dYPn?4M0j{}DtOXnesGpC&1)>UX z)Z22@*o1f;dS8Vy8eV$!JgUBf-NhI( zdT|!uUWIWIPF$jpVJ=6i2|4{7kBobyt_%GSp#G^S)5|qy@aR?|kMKZa3t;(l9nUM! z&qm~K!b-?Pt#x7zu)CI_AgrxITc;yuV-TiuxfjCqB+Od||7{VcVg2k8H;VhjQ!-q- zWFgkT9~6@kr(`QXR}Lxfs3~fZTBUAMFH)~j?^1uIzM%d^{U5_j!$!k>hNlcK8s0O8 z8y6Z^8qYTFHePS^86P)(Xo@kFnFdVfnQk)OZ+hDFk~zdY*LP#fbMJzK)z9*%i4V^0+<8-eW)AetuL$R7zBT)WN7@j!?&9N55l( z<3h)kj@um%JAMW6q4Z zJ?6fcKgE0;^L?y2c1rBov3JEj6#HE4t8uot#JHxo?zq))=f&-eI}mqA-1ig0Caj;} zoA8hL()gbEHSy=i?}~pR{*Cz063$52n((`Xe*Ga52FGtS7^nz1kAri{ZGk7vA; z@ovVKnM!6p<3{laeN#J?Y9x z4^8?$yC?gs?8~$7$bKaIjqGnd@tz9LVo#fAlV^|T9?z4WKX^XM3C~H+DbH!kIX~yh zoV#=Wkn?fQ$GNq+H|E9W?aF&*vT^d9$;&41o_yQnCnolDA8prE^LL zN{^Qnmu)DwlwViwuQ;V*U&Wmjf38$3D=T{{e^Pl(lRsB`BRQ#1Q=W2dG}^-L|9I(_Qwsf(v}OkFW`!_;%8ZlAhm>K)iwgs_Q^ zl=VWHSvRjqJioeU<603e?iS`5t+g)k)0)O6m$-QDj8>O8WB$T3 z6!-S7SueNzx6i({>($UbUh}p88)u*Y&QJeUK|x%T9ha^Rtnk z^Y~fEPw(1IYX)THfBOU~C9p_O6F(v6A+7L}WHZV5CV7z3Bqi|>a2zAIi4kBN81X6!44m{FohYDtQ1L*ER)DQHa<*NB@S>qw-o7B@on zp}E$|#&b-iOe`zi3YkekDM-lTa)w<2McGIe08L1uBs@vBC+YZGNoo2t=F zewQ+aYiRwMhK}P+%JQJRXtX~9y$KXbZGSHQskf)sWNMWRTL5|!wT00v7R}-*@f#i9 zL>*qj$!fig2tY}-S`la*f~9{6tkC=zAZyIRN(vtyC&@foDaftJg{~t$fh4(pxI|#I zq(w-QC%Hx?N=KumKwT-X!Z*SZwWnceYeH_^|9pb$^YYucE!7rU8$}NSH7##HPf};b(ZVyl+={2luJ^sNmWvfsS&ABsm|2+ z)Z|o8>a5i6)c*A6zgNH?pacpRMp!9skTw}3lVyg?mU*&NR>&E0j%<*f@>IDNFvJ0d zrtCw>68+9g?_7(&cfPYsh`0A5 zta-cd?GD5<-)?$)-rL@{la6EbZ`@}j%|c*4gt%6w$uAXy5{|LPD~U>~lBVP-Qrd@KUHp0Zlk_N{wUW{j&hrFP`P&`S4$~3;fp>(<@w;R%47PQBJ<@! z%zTsFAkPQZD`getpjGBbFKD7%=3-8IFc;mB_09t|g@fXwc^65=iq66e7GTFI2Q^I> zv&CG<>kVQF)<-WS$5oJ0*MX)gbuQ!WZ#X)Fbhr}TLcD6+p%Nt~v_*l-9i84+m$s+JH186@KyfOr|7lj>d0(PfF>|v9zYkIK9 zO%pZ33k|ehRLgeg9-Yuy+eHVY-Z|nKaLA3&8qO7GigO?ZZxxq`OT;d*TU;-GDt;!e z72AbRJRlB3Hhx&#FFV8$@uK*>ctQL@92dvLJK*DIf%j|zS2-JeZwvOz3$bTkfZcWn z_TY=K=kCFtdMQ@LUhL#Mv6o+gJ@-oR-9 zjaVw4hZgmm=oT+SKX_GiiI<=;y#ikLXYjqZp)0Hx?}2lDAU25ip`AP}TEuVVjq>O6 zdU>01+ULp6(%jGU;1^eV>axZj_H=tX*1}*1x=ox>Jv$3*kWj(k| zoxE6HB1=H|ld$gN!G)6LMCp<#G8OzN9o!%jdsVDFOP($_$us3S@?3eg+$hhGo8=mC zs9w2Dsez5-aWFotGUY6h^`8XwPbhc3C{zw39vkivg=#jw1>#7geHckVc08S;Wd3EuyMpgx9jWq_x8oKT8y zXCP2~IYQ2l2T8C|`QR`CZLh){J&5oChcvldpTh?rKR+PSB+VhEAHtd@i{p#%T)#|vrr_9H>P31v!9Ukh0h^FAv4Q)J08foNRrKtAx#F{Kfd3I9O| z!90eY9OT!ii*F|dC-QMCBe`^6lFW`O)f#`?&wjgZ7x5bF}B1}ezK%n_5N0~W# zUIofL2k!idNR&?@Y(-eDr!N+6xnIw_1o8jZ<)#h!lva^wn23P()i)7uNBlJ696kS8 zEzhtH`R^jV3-RZA*=qfp7w_+hL}M7nLA3Nfcsk~iV&IZ^0|(_%j2nT<$VCXxfo8CF z*wacj3PQIw|C_p;cP&e~-N1>fux5RUo|! z0rJuiWIYOg=nX?2AqMY6sKGnx`=c1&g!BI*p#EKux%tl!@sn}>-~<2@0r@ITJSP=M z8_UE!Qk4eCQTIX~NQbO7D27CzG(r~mSem36Q>TNBtg}-V2b5A|dDGNxPUVqagD~L*s+wCY_MHV`Uun`a;MlFJhm5Sx%7gkj{Pq zX{Jbg1}UQidvl^p5~Vm#cwc-kCyKJbJ`K6=6-W###UHVc^PUYkuoC+=?dcv!B2&bt zGDrMU=8CB@PyAI*hD5ST=0l=dEl!0zR|u(K4P=^HNLzl0!|NcY)r(Icy-CO|>t%_6 zO;eVN4UkaFAz#iB|AI7GDdx&5NSc3QiR?@{MQnnEI8Dr#)5QYm6$>E|R?8YlEe&E3 z(Ir$7Q~g(TW6TVyNCh<}r9;wbj{4#<5U;_T%y$XeHl>tz?@ z<{n6g4#><-NP4j%4pL`4$(?c;WSBnL56N}~q~lf4c21M4A=|8#>mb2ykOPo>HbV9} z1JciC$U$eb4D=J|W#@}ya*Oy1GVoS;q4-*SBey~N-2thXWU8HzzepOp4D#4sNSG0j zDw82!8^q192Hgbd?;^;*CP={3*-Fp}$#0tQib#1mORv?C3!j2*{{ir$haguT#9De3 zyPOUDW*a1QGkDT%kaiD%2ZxKl<8ukGxm@Li*$(d05^DnfVRy$K#MQF9WZvfz0~4cn7+}pXL3g z8OsLx&*(SLSku!xux_n+#<~^j*7l!ftLa;}v8T7Uf9*!o%-$Y+K{T+gXQQc>-)yzR z-_5mp)mp8pdDGfe`2~fAw)){BhFQyc2F$bdaD z+yz0o#U*O(+7)37S8iInqGw>!n$8nmR zEnP9tb4Gti%g9Gl3ul>HxtgtY7$K$}4eLF6FFiWedq+z2F_fl$j%@wIAI$xFo&I1n z78co73>Pu1#4xR^^2o9V8>o4eUSm~Ijl!Zb9o1!~)f(zn2jQit#S;#N{jSw)nS`PHM>a%dXqi@n|K1w zKg0W{R8Xr$fRn) zGSCtm&(v~k8&?kWYkAh?>oyJWSL^asXXxclU$vRDY^P&Et<{qKt5&Sss1;nhN^el> zgG`;=2PdcxO6Yx1QtLxYQ6F56`rvX}ADqSk=>^JXHNfuUDYQ`gmr{r1$*%8Tp3VO#|!rBcQeP zuUWtGtke59YG12M%sm4G>(1P?UaMVEX5;q(nj<}{+{#&f>&{%O=TvAptNWJ+%9Lt3 z%hqjNsiiip@8i<>Mfp5X#AQ|{yxw(dmRXthD8*nD^(9_ZR31v`L7dmyb9z4nmmHT*ENQN7 zak+3TAHHa=&$PI;*;kz4%WCOf?%KJe*{7uUXovK@?qvxnDL&ES6E*IchhdYe>8{T8 z$sC`ndwH%;$#JK+Q*wQ3j;rrJHQFhvYkZCxS9f>yAtkz|`cS%B<5OyyHoJVGZX{}Y z`h13l&4(35K{LLT{=^i@Jsj?o)rl@7-PMO3(t&SopJ-_AZ#f(zsbsFtkmFN5K4(oc z_2-MJsnH80xcXea#~OTwjMl?hGOVVyx7KH>ZBFs2=`D?I%_y9(v)ScqXh4>?CBfw@ zr&M`Mi|de98W3e6OMi3u^67g%m3*wB*#&Iu>~Z;Q4b9!ianUy$rAjGP+MUo1*MS6J z%NJVH>l2O5J~5XHr{Fzdt}mHV$#Z*-M2cQ2al|N=wY2p0zy-q7(xQ)`#np%LxT{-o zea0MDt;=Uf@4=YNH4V)^v%A`7aaUsoP^CN9XJT{%6Ry5P=4I6``bfh~(7>msPwlSl z^%=8M@Uh0V)3p>zxC8pNhGgIp#><6m* z(ohSPhldtiDV*rvU435|4sB{NMG5YdmTVAlM9v{asrB{s%*^#g=3odemoK7b4vh>6 z%#bgV-Wu^1$@67LLy?RW7jV!Ez<~B7SGW5-CN%MvEyEo_h z9656rHP2nFWhSH`-@*COIfq14&64IrQBgHM*;DPaduYjlw5kt9&?6ELpNzp=sOb%G zq`?FMUDZ1=!{|0LJH?G^fi&oWzEc&fWcu2Ik<Fz0a6Nmq|B|w+C zzQVv<9|||s66yI0vJV-hv$h%Bk!Gns1UqM-0z6tBP(iI{X(o~xdC z15i7A=gxX}J$O+wc0jOSY)$1-I-`N%N-(h)Uo46lz*p0`*r8BS?F*^#^zY1byIfOt zqRpz&#aww>dp;9ZgI?O@>n7&nUDSM^!DVzM+-Jxz#R&Fv`IXf^19vnY+(z? z^-T}-#@N@VtD7X9u|p0_u^0R!kFZ$hi>+yHNWkXmn$nVYC|^cn-Od>GwK1V#)Ys}! zUjuc6+Z$V>CdXIl2`&(*6zFhfj<3qI6XZuUwi7G;hhW2m=K1mgVl5Au$T&m8d=DhG zYHjF5&Tg!MlR|Swc+p2QjDp!ffm2)d5L2+i9eKV|tnxWOkUtmA$!LeK3}xo!_$m<1 zCoI+i-L5+9#sO?C$RUFE%?Fwn<{TEH4v7XNBqbK*9G0BZhy>>>rZV;T)I?<{v4qM{ z;uI=FiKRLBffv>w)r=H2d8Ar$?t^KNQmsg7d2LiqQeHci<5UNg<5VY=<5U;*S&L%b z)F&l+s832PqdqCoOJ!yu(MM${(NAS4v7E|KVgT$V zGhCEf!*Ee*EyG2rb-?kI;R#&NZ$2+RY|xT3kQ^YSQ!!sP-cH9}izJoVs3ob)CN4uC zsLUCtJaxF$Gx?2cY}S%g<18&n#m`1NQ?(|~(UMf)TrEijeuA>ohTA)j-?+W=wImha zq9v*L1*kbqYj3NTqyiUeNh+`nWv36fx1Ha(y&YPTieIE9srbdHIbCb-5-mvuc4|o~ zuq)?q2#dHrQ^H|`qSiut!d})=?eSUqeQH|6=D>!|jvAmMoW7TfGjxBuWZmBm2d%L6 z55f9QzD-70N(1j?lZV&9Y5fChMY;A|(=%|I$kU&vtvO9(tl=lBP+22lYDd8n6pCPq zI2OJ)JQO|&sj!!t6v~Bv6|DUCBlQP(B*elhdo8>n4#3jC6E@cKV5=U0g?u?Iv#p}Z zx)JC0vyiTaJ-rlmdnsN~KF0GW@TiBsIzO*f=sv4!271H3LHGjs+8qI! zO>R9rE#&vY5L$>9{}6I4v1;XG=xBY zAWlYJoGr>0YBO;xM#t9g#=L)}y<0!m^Ywbxk8CFEyVf_Ye?#3n=hhl5r zn7DJyJMQ&xxkfDxu4kQx_UaHQ_Kt}wgWijg&O@L$YfPLP^qz=x3k;uzSn$*nc|!D_*%pVw0yz`ZcOYX_`)!s6l?qg`P&fA8_5rRH)D;4)rD2! zc7&H=^T3#R^_chN`g^w)57%oQc{ewquLTGc&(h;+#8VI`zZ4-~kF)hS4RJEz1aTY! zeUCy2HJi-HGks_Jir!71n@y&VxRlBCuJ%1FFDy$NPgs^V-jUe!CgCCI{g2wa=?_{Q zT+j48+J6#(;z!2BM}poDBYigl#aO?i;v0hAe~R>e1d1=yGN1C-Px0VCva{3^_AbgPdq*zMWhGayTd5LrG<2H}!Uuld>xo)?X zLoLV->RooIE_!lK80Um>j*Dl|MN*Y?!J80IYC#qah2R;ZoWsvO{M^Y;NLa{!nx66_ zJbyxc$rx@qfkrK7C|*2M6sna%vw$?!O5s{5Tq}jjD+ej9+NlNQpwpo}D)?&!f34uJ@ZbS9A}OstLt|7Ux%@Kq0`#(sVOz$qEo1nXaouGM!!oJ`uSc4T zWn8PBYqfJ-_7t$ zxf6aSbno|P@Nan@7x((%QTJQ~XyfoOr@Ps7OIu=|Bs{JpytSnNR}viD|2KLL zxu7vi|0VE(lm27$99lt;3^A`Cx{e?jx{e@(hC2z*T=*VB!{wX~;(bH&^(U3-=lpB= zc{Mx}Ny9A#By_v{S~Nwsx*d3@xuOuxcvIlXN%xQx_~uzL{+J=U6CMLPjUTaTn-Pc_X~2 zEQdhJ$9*LpwuxwE_70c194p}!t;{Q<%Z#+MM}F}G?Q{;evr{o)C896Ix8sM}+5Lm? zEF5lUzhZ>P8N+-9w?o()nt@+II0oB+Q`C69(P1{#Y=~49lT_we;W?>Dv$E1c0+mVh z;%3wcG93<_3OXH5RE>%PMx*RDOQOf(()1j`F!;^h-^g8!poOkXn$^*;Hqh9PAvrY{&SbA=KquGU6&_3+3*LI*LjSkU1@ z@4N)70avrIEUc;{Trrx}d6-FqSv9P%fjrGdtC?s;birsUGUf}T(bR=un=(xc;^J`U zI1%HczYcqdWunL7D2gp9OL3-{op?B7@yMhx%gKtG^+w;NeX`O2W{GtAKgF#L|Gj+} zx_mA=7F&q|CU@5qa53^V)Gd*x*e<_GW_gRaF@fCX=xX zla2A1&7#YI$UggI0qVTW zzhJv;s*|a=)<0SIO+8T!kB=!LTBiuxNxU`yr2^iDLR3e7#8h47EjPpO2>#y+hyq9v zDoo-lFy{;>sKH<`cA2Enm3GsGH&9v1n!X%Ev}iw>I8U?d(Ar+JAyqiPjG78(KR%( z8cjRn^tBr@#rS>E-lDa$U%twF3*gcB7T_CuiwF3gAm)4LgqneByGbz_W1yeMsvs5v z?b1e*!MMVLod%*nFRe*pDwqJ1i5VR4+Gt?{<3Gx7wIpeK46~S^X($4_i&Z?>X#C%d zo)L*1hqjxMneiqy#KaRtfcMDP0bz*9_vTt88SRP1At6^7nNo9so6@rkE)2>{5wNw7R<>y^rQGyq=US#$Kj34NMYBidbQg zG<_yxK;j~tVZqz&w06K)fH5q11o>P4?m@&9W?4MzL?vpE-XCp9*-XQ${v~lOW85V-+5k-4V$c#^DMyFTr@*+e6O~m z11Jm?q1Xec$ygQ^(I$zBzr)gvYz-_{Y8z4^T2BUGWH~Skmp91_*`B0(W)V8KhU5bf zRTAhi-(^HvVSj(TxV5uhi5+}Sn@>sBmq^;H!fTPiK7jt$c`TOFKa9Qbh}bE_;Ee@ z(Ar^oD$-~U`ptMWcQ@}WTA97_x|29SA>brBg4Z&ED$18 z8xS;)B~Jo1u&tv8)PPusfpMO65{Av0Wta(P8H7QzGgis>f3&PUYj#zeu{&$_%;L_@ z?G-ai%g&Qa4}{d0m0td8y^b06_uON-x5OSEVv$Bh4xKbhbqg>h3|+*QGv=F33R9@o zucm)kNbt31x>fu&W3tLp9FCM&ySlFIs!h$Ct}g9bE~A?kOXqSWwtnzQWlH_v$Kccc z74n+xAQNq7lylL480`io(6J`)4igB8g+YuhOoU-Ch|(CF-X+L`=;+nLW6lOY7+5GQ^j)c6KVBlQidkSVme@B#%HT&tzR(zUn{PA>S+RaQcr(=Pa4)f0-2V zB~ZdyzZ&u@vtg{TVum3Dg@!nIpWXojTmj7lDydOf0nD(Dh0WS#vqjh!9qa!JukD|ITJ!>b=u z#@zV2aaaEw=Eho?y|8qTwd)Z#CfHM9OV@T$SVl2jkkOP)$WIVUyp1uA$S{M^6dS65 z&aE(vCTP}5P`J@#HW|%Zu+NbtbPM>SD$VK&s6qOOv48`cts@G`K~z*E>Ch?IYjyhq zQZTwW-6ffBY`I9uH~sG(=xBQ#I{X9cuaU_II@(|FD3NnET-a8yuwi~)_o1y9_E#-j z*qYaUEAelJm2uIa(p+Lr$>dsX2!TpC zBgQg}+i{FLW9C7L)3JTc>>xAGzEKk*U(wOOHQ3q{JUu)wpe@2DAdu?$?$-QrsmL7-l3Xv$b zz7sqzK)cj_H1VKFX*5Co5=LbntYlb!8bc;KshQO=5GVv!uwYGUwlW`vu#B!q4GVPV zmMcH_pyR#wv@Q=Ru?On>Psysf1Kh1nh4Xo}EnzC$D?cBX!U(cNvD-T#4v^9DgE2CK z^lAeHN9I^MNx?R6G(gqfBA|A|Bk`=&6|Ru(s75J zM+^~@J;Ed)6A#NxP#<6fF0v~T{vEyG0EwmT+m!ObkLv1_xWQ+2E;zs|A19s61s^mz%jW8*BPg`OPLhP1d%+hIwHj;ff4du}WMgjncr!K~f+C;K9 z!Da@(B(;ZRa{$Fs1mGe4Hl6fus5hiy%skxjX5XhBUHH9;8U9=RH^?5@=6_skO??ur zMTtW1Tk9G zt;J3Lo;&cEYcNPQZdKnPc18J+?0$vm1a52Xn9x9Ho%;048c(bcCS_>qc}z z?GLa?hhRAp%bL`Fpaaay!4J~>RXmiiUq9AY{NRJdeUJT`V;S!MvJ+D#I{jbD@XjF| zxDNBrCiHF>u#efpLrHf;N3>XZYITtuHmqoSw1WllSO<0y)s0QO2nra|GB5UU$6LxJ zImS;rWPhqm?AYwT;r-4Xa@iTJfjzs4F@CDPa&H2bi7cVR*zM9c_&@Ri{~{%{ z*VuV6TqioW>pMavk8zzo#w*8#zmj(it;}Be{^(s}xSd{%Q7f|xzxXuDa)wQW)}kuk z0{jj!&09t6RXQRgLLsBj?rH?jK%xsQS56|iLv%rV)oF)hW>gG;CSs=5T7ttLdl^m& zv4~hED=L$Br2ebF|6i$XZKY3UXFpdU6O)=`>Efg$f10eXbpItaPlr=go(7z!>*GBi zyn!`RTk!d>3OsWeY{!@53@AVk!FBh5-;7^(=WzQw<@-a2g%#(^M}`hl2jjr7TmBFX z=M1;AU%of=D77Px3_Z$#YUj&!z$a;Vi@XcMY>G)uik}c;Fq^Cp>&*^TfkHDcip+)B zYO(vmTxc?BnjJ7~HfzY{bAZHzxLCC01cK=#AR%ztJc4IF1qdKpsRM_0=m;mnVwZ+= z90n{I#`_F93y{x@#{E;nW7>ybO=A2hzA?DJXSmE2@^k$1r8e#-dE6?|k$N@8Jwc>^ z#ddgGL zbem*Gx;xc1F)@M9LkI~ZmBKbj=Zkdc3brwf3Mc6T={nh!rI6sN#1%YOJEhzI&&)O* zDVfsR?~l~5vRx1FoHG00jMz=GaKvx>36Rpgnpc9nA8r=(TC=^EBWFCWCJm5`Boe0Mn zuokNTuaQb}2!@mp9|5IEodA6i3UEL-DkDb)*aV;*LN-w3dB8P}uLsbkr3=~+21xcj zGq!dJwNMjflRv8jgo?yGO$tq|sgSlA(vNz9jg{Ljk@5Dl{k$GZj{Qr;o>{ z6(x(NJTUqH3tpY^HSy~9_L8S`UcE$?G$sFQaF(pCa=+!CtnU{ymEXWl(vYAVWvI?m1yMlJZVg^USi?vRIg2v8EM+{}LEZ z5^^#Mi{_Nh%Dncw?CkH9w!z!vl7BT_6jz@xX>x96z5f~ajc%P|8+ZrY#uT|1oPU&+ zLWi~sSaZN0G22@gVu9fmJT=+?5TQm`)p=$KJCji9u+3xw{wx;Wxo6yd$7w8N zMhHrgY;GeBFTi+6a=~$1U`Z=oZS5Kw_w8$Mdq~5ik~H{!ViF^>qwbVb{O<*0(*Uj+ z1_udb%araUKq|;RR>1k^m zM+xK9-Gg5$9B&^s!1tKzg3fo!x5wRw4e)Rdl-VWEc( z-i=sylAYL%G&?DrI#|^SIG*5`h%6x_@H1%IS%>b;VYkzXkwdpnz!Cz=C~|(@x_Wi@ zsnZJa!^r4;OK0o0j`~XYlCnN-z|LdG?h9KrPVtFDJEOp%u?H%US%3*Uwjc@oz#zr8 zY4mC`X<8+5XI}Rn7)>3p;5k_1GD5c`_n^S~36d4Be5bpz?xupwuloDH%4}~hc#gQx z@rGGd>1Sc_6gMSL9K1^YHQoKDJDsQi_5uSu>r>&cD?m+`%O~)w^qN%iO+c#SGB0ao zup?cG-^62T<^1i#b$7{&hwjnq-oqSMJGcA?FdZg}y!lv}aG9aQOY)x4%uG6?VC$3S zwt+>6?Ve3ebTCI&CvC%*SLWYT+SZo*+FIG3UgiI#^2-y+i~XBuZvcI0$J&`@TyQ@0HbZP#C) z{F>b5-=z2Ms#~(8t|u8?Q~zR{awg0JW|8g9#EGM3>B1lgCqxXFlO@M(cN z0IO|KmWviOE>hyUx&}Xz$Nj0=c*KREnt(6Cq#yDnSi}`Dk73C zH_Z_slf_zoJLQxAKp?*xG6BlNO3FS1LwC?_32FWgecn^h(xte8!EMrxDjwzer5%;$ zcbtBAQ@4VOsQF=o=md9m^bUObw4_y;@7E8+ zi1qJVnX_DqUq2mVv4d|n5W~`kR%Wksouq%`jD+Dy#cy0PocjMaLHM~Ia7K}*Vw621 zJk*M{Wq?;9;MJK+V0(vyD_g>7`xxzHAg9e5XzR+FJ#C2vJ_hxjo!e^aO3Sv%Iak`@ zVsO=;&H#nri(| z$hHfwzWPExp@i4(mn_>%_T~`(By&CUPpmRF)`P*2E%(QG=YfLT3fkK}&##jm>FNGk zY>G)A3+Y7pZT?EDiy62~-V@a3hT%5%%h&PC$lAXC0OJMLhN0^L zXFd383?%#M-YItc|1pY$XdmMQyTi#Dm>a1hW8DFm%LcR&XKY-+9l%ZQ0OSu)#2!2< z`~hH0fJ1;hvG(y$tS{NR_UTB(rtdF07gj9yj?D#_)h8P#)R&eiH9({ri!R)MK)TY8 zA5ZYVt4Sz?O`2g1J6FgLc!o)q)n=GR;#bec43mbEB9?laq9Q`^pP5WdjB^?+rU}tx zHZkJV1Ns#-0gDY{sKvO#iW8`Szy;erlmVW9+Dm0cdt~ ziVJVD(Y92heH{7tj>(_(^?f$Et!?VBJRV5KQlvsGUgF6ftW{E{q`g7*Dvj^b-6hQ; zjf>`hYYf{R=3M$Uh7n0st|;4x4klkQU$$nY`EQcPCrzpfaI0&XGr;bO)q1I1ILvFW zWnPPX=C#+4%WDauRNMj6_G|LM(sY>@=_JQ?kG{7wx2jI{x95`Yy!cwZp zxez`SN$wvbMmAl!Add_??qle#!zC$j{=iPDd9244!6{%3++HVUWk1u=A&dQ=XJkCv z-IAJ;*|edlCDonUGC*7pz`!FH@I9`Nty|+noKO1*@_B5(7(F%`!%(uMF}TGYUQdFw zm{VMe)e^_4NU2l|FlYrH{6Z0J%^odYLNLN-Dy6_h&2{(@`Yu&L3XU)kH+8=1B-&s?}PBk zdq^r{B!JBW*q?Zf;z9R)BEhq3z15B=9M3`Ok*X<_Zfn3v55^e$*P@C^C6$L+1VlI5 z-=LEabA!ecV~-(^pBVUpK$Qg-qw{C@JCndDnmwt-;|OMU?=>5@C*6g49tb@%B}f8Vj3_3>@OJX)g4fwVfJD5xplp~z<<8H zpyRE#a1mUi>kVkrK~g__w{2D&9h33EY}UNEwTm}sJs<;vSF<`t558}OmIICi0gdk4 z_3NRkpK(U(SxUj;4(@Mp-OQPFybq(>PTb>9#Z6U>QqK_YYMzMi@|LMnDvdNjTqF9z%0U%<;N;%R@MZOK*J@n)W$rHGeKbN zpGO6`61Z2dW{h7Q>_?zuU#CABC*y)8y9t;@ZB{-67sCH<=FK4s1|5!K2H2H==R$OW z3H=B3d>I<{c7?P zajh9jCU6lWuvbNd;zWZErwq|pd{Wg8r)VwG9Y{Jk=Xnp3G04V6$cbW$l70n}Hmm@S z$iH35BnS5Zu1Hy>bEr`$qgyFD|NW3>OdH8QdCBmMy~jJ*L~kN_V`CXXizHD85b;Dg zqjX1j^i8`pjvi%2>qG6^jOVnyD8oLv`pVDzU+S~3e1Nw8jhBFC?-+LU3-%IlX9h+` zUIOe|vf`mh*$1P{^Q!XlnWXc zQTcn6%AkCr22$Q&*-#qBqlQs+cT2RXQ}^#MJ_k{EgbniIsDuAW`oTX5i(qK%wS)nZ zM_rj2tE_C;U5R}C-QR8KDO$O*sAt1~7XSR`?U!HKj^~w^!xlsIQ!i;|LIrM&koF4U z+bm{dwyf_##NXLzU4d8J~Hg!3W1 z4q;lvi9V^3kcJgp#ey_!T*|vGNh$&|og*qD9Cbrr!!}zdkY$lfwIxs^OPspKAgAOz zcUD#HbfzzxI1xXP;s4U%@EpM<;U_l7#%+qzVW3~{AiWjdwbbEQy%m@TjpA?!V+y(g z&+hM5xM*vAYUdXuhRgj`Rr@Q8UEMDHllP)O&ylO*&>i=Of#0P(h!I!>d{4~K{$T7P zds8&7P3bDKQ!X%>Ocs+R%5E^ndCFuF{HD`YwNNHamq~*cDHGp)uSr=pcs)$+ANlE; zAK5V2pNf6=!z}Qsqh*CngD=ri+dvz{69M}z+={>u?Iv>~Bdv(NuW1`EOHl?6RF?YR zFR3`tE6;`_+OrMv*}6J^d4p2EjaSp{l6EG>72lSliNcLJa6DCw(av$vk#Lg45XZM= zG@Cx=$V~Sac#cSQ;XO)z*f#l@sCL?;U>9^}WQHxrl~UfO^i}YVd>i{9?mEMp8*{^> zK|e(PG$ddOX}<44TeO9Tq)Q~|E5#|dyHD?FXqQLi9{)picx;e+8sH$S`IEHkGyi|O z?;yYRU(wqu*U){3zr%~#Ub0Ody67VRLb(q&x%c3{gFcoNV{in(9a=gx3TCN77UGLMfT?tCof%jxze4U*S=)1T5DHXsr(A|1&o0fkJVz5 z7TrF_%mR`rVN27b98SAtNHDTI#k0%&->g{^keZcMBRCJ|UB!YQ)4=7m zpgRVEA=;h8_hic8?OBqUZU?J&8i8|z|DxVG^)jiX(!W)H=kJxRT^Ck$*HJEkJDSYgzIi&;9IDd75d)?)~s9)OFwH5xxBa`9oF*xXv%JL+{BX`cAd#R66nXTW-LqyMwzRaMKSV|is|NA`=J%KIwH@2~W{n5`^1 zo;&%CQ;5Wl%a7C2F38I}l9RhN4aY%r_BaHXhi4}ga{?~|lQ9I^BCmxAeJup84?u5} zn%+3x^#M4)XzreHe$iJ0Umk#ODcN|i624kXE5ZLR+z-xfKjHD5kV}@Ar>EVtV zz^~0vKjInq^Zf`{8$OOg@$tCYE{AJMeV@D~-sP?ICu;5Df603P-*7b+tH*M+biVmr zLR>9CYo~r8GyT^{V|Ln81w}KZaps@5MbG%jWz#bYt25g>@asH`tL5nH|G?1z3}%@v zh1gt2I2t5_u^bI{9uS{&9ho=Xy(@XqqU2rf>GO`%Wo1<^$c>-SJbgyXg!sG#l~q@z zOuUNL8O`KU%;bN~&&mS)%&GCSzf9jzmzi5P{fsNFxI)HO6lNTmJJ%cN;ou0a$Khre z9tjWHf*!@qh*_s&m&FgQU{HPj6Elkno=ng3e=9ru@BO{Hby9UfR@Jl@XLOWzFQ(Pv zMnkK4H2*tZ21tI8mjSaHFGE7-WtsjHwMXVnbMH=mx^`OfFMg3M!B-BuOUCehUYm6M_G zaXA@@oGj@QF`dN8T#taB>geH3+MAqpZf54WS;>2oWaTvzQ?9Ba(z-@APMCoISm^xZ ziLDc0bO6U-h>6a4X8vnF21rMi02%WGd~A3P5X>n7KIVFOF46zfwYAbX{odr{dvVVs zBYwjA%3G`a&2q(r6%(LyB#Q6kjmlfl*S-bqeFrO%P<-FPN~C?miS==O`&Zzb4Elys zSNitdxZjrZx9`XOwv_ujG48j6TyOBCZ*Kg5lB>C%KkzLG&VR|X_}ft6TM(T8k_-z3 zW_^6>$QbAw#!k}o$z%Wj@iG=K`0X>_&{27rnu1-=be~z3g&uR6q&u_bhI!J~HEEvz z>#m4$<#1hD`QU;&qM@Oq%0ZM*#Tgt~J!0sW=Cpj=M+)S-wS27V?{JSH@O=$`S5S|> zKS%Y&_o_MYU43kv`rilDU&Y@Idi|&L`hY|0Up+KV{S!g;WBEIjV(xz{*FT{=7N`$N z>16l^gX-TiPXB|D2Lj_eb)5DGgW7N6?=ZY^|EszGLHY$x9sZ}s>E9pJ|2qB-d~tnk z{Qi;t)tASqKQvN*Jp4kyADN#Fu0IMt_&$Ok4gaJ1C%@1D{Dw5HZ`I-3f*9wT(0c;o z8)tqY`HuAe!AbDD#lPq~=;T{;>3i`8> z#;*JG;CyTcpvMo>(x6Fe7jz;QV7sQLMbWqvPv7|7y~*%S*~0>9}HAm&&U!-?8LViQh_sS2|`J zW(!!Up-$6<-VxIUSO@>WAD{BYir8q}O^fDV1&KAo8-RSSFlY=PP-5G|Wrh1a?UT)$CULNo5C7fmr-7Zf9_a!L+M@5AmsRA>OvRIkgwW4@7g7UM$jz#k- zm75nqu|fGmDDUC&n8HGjF%!%v))6bqR&>oSMp(E=xw&HgqQQm$nJTKf{^ z(KLR%!kk&yT3At0)S{f)Jg02%=JMIiRN9TwztBr#nMe(ZB?Q>L_)PR-4odR^1!Yu0|=G^gy1rp0fR>%DxT z*9_(kZuATVC;-)vs<}`i2cFpHa_|+NoxmA!yf%a>!IA@?OV3-%O zbrnB%gvsPzg~HJld7TtqH+SxNYGvo()=mxMTg6k{MxqzW1T7b8LbC($VK_6@nUy`{v32lUgQK;8Z zq?foZy({5A-i8wHPm^y_8(JMT{>SU6@q=;7M}ay+Tb1ykUjtUxA=Ce>>`H*6s?zm6 z=cd;z-JMR;>1>^(yVF?+Aqh#RLzWJNB_RtWBw^`<5D2Ik5Dir6(YbZ z&%b_WZSxP&`>auEHW;b`@_x6l2FMYmCN=_`<{N%^^>*DduXx$cs?gpS@r3!Y5p@0y z&`sx^M~||3KpiIC!^d7`E&Np_h$BDe?XVB##V+u6CG}Z_y~x{P98ocj_<^3G7|Y9S z8O|rP$&84bN?za+4^|VTdof2^6Jw)sLglLoKQ#SMcu)dAp zdh>4+O$iFejCGsbra%>dI_Zlwlpi@#u6@nJ@|%6tzF}kAuL=*zFM7&CwDRD1D@kUQ z6`&OZ)$Am5`ZTa%?Q6Omz>=lozudoS+qj|qP@(*yCr~zH-+H3P_?(ahIwf%u;G5Qdi!!5{>*aGH>4{d*BJ?3qzEAy4*kQfs3Sm%oA7?S_piO|v(G-m zTXOnm$_~*EbViCV3uO*Xprhl5P7PUVl1Uid=1IaEL*qDZ7T5VD_v z6bIow#NBmYjDy15E+$=1GYxjg3A1<$mo$ z|I8?>2z(KsF^zr4=kwWE8@*K3fz{ETOzg`U>MYa2UC5v$j2}chkdWg@LINe!3X*9Q zZTNn;E+bP!J`6zw3Bl^fpoDzHq73osc9`68FAC>j*QK;@bA`iE(Hu^@G_9t&!fvm) zwfuMGco`dPw))2EB-W5r-B@q8({Jsyq!8;01iqw2ki6I@`BoSbUL^pWmCccj`JQRE z1dG*|!V@h8If$5)c!78eZ+4qvvEqpmx1p+wQvS9jvqU)c)kI3bp+M}KBxbcrwBv$y z8{pN(2cWp9Fc|RX= z-nIFW@n71J`hlu--&U=wAK*<*cdV;h{Ql~rci`8`nM?jOc#M6qWNF!bGv22aiz2>+ zqVH9v@7nUWH9ilDKUAT9ztQIXIHl+IO3VxmNdVymn|RfdDhxC)A)&|8ae z4DeAjepk9~(z&oqD0|eCE{feyh2RkIl2BVIuqFQKc*j*(bi^CcB!2e-?G1o(B9$2L zY8Hk@t8Ex=$A81@p|xBYm_K7+@j&hTf$;6ym*ZE*9k8t_tif+#0K zd4!(kC*cYv)ia6YxS)wbt@KYI0);Rgh?samlMi?TY1egb+&FaLhFRBNKlI|#kKmF< zRZ~U(hz5H=^e=l@+!yijL})Xr9SR|30*nWm!1FMNq}hRPGyxkM3K#8C13et7vG_-T z36;(%H;3L;xG{e7`TxPDkKg=F+F^ypwhLpMp#bq3X}YddgJL20NCiGAD$oF41uRA+ zQV~wK1$GlMt`a&t>8O#=|FeslROk#YdP6+Okx;>qP7+e={$-=1qfxw<{-OWWv;C}i zAlScfVSjLdg;q9d=U7%#lqiWIt53B_jf*ror>@a`)74c{(zUpl|8i_uS+|EDo&hg2 zQC`EXz#K^wRv7}+YTsl%T1GtpVvjDGoGd3IfS?fK%f%K%PoIvSzC<6V1q~1@R<;83 z`^s91F&#K!Re9X1<*;*1K#G8lA@&mcJ8q)ua{(cEsEpL)W;gD*J90N(J0d%F4DHwv zx%;jreC~iP1sQ^gZe`!%7g@c!ZApC&xhV0F6Oeh@haRVhYJ!3A_%Zzn7KN}d2vL=i zV?G}peSw|Ra$Xo6W#6utw_*Y5`i06dw0%sp4SSl(d&F=;Y^uBuUR)vZx!QRp!?g-= zOS}uIRXk^3PtVaF0s}&;t?2Uv@Em4{-Np?BZpT^xhXw=>B!(xfhio9r3SZ#YanlMs zO6-ES#R#Y{@FW?YU=)I?M?YaGwuH}WA8%-!Ucaa2JytZ-=$p+JhF9Rgitq~j-wJW} z2coA1t9J1&LEoV$QCOk)*+J1N>OMrp66^|L zc(U!7vRwFCL;#BXHaMgqXn=1V&S5QWADegZhRixctq%@s9sD2h$(4C=y0$lQX_Ed%iRNrAY#?W`35KO+5q;9m@+Wcz%cHA zn>z8IMSVoiunRTp+L; zztGOk1-4G*+&N6Cuj{L?6JxuEeFQR1G`4sShzHP09z=3;iHZjW5J(D6h+9wKIf13Z z8XB}??3p#a^}TVPofZ8i%@!_krFr3O9Ww1VW7CMg82)t zd$?&Q`^Uh*8*jiih}W8M>|P?J$U}TOJEcdVnt{J&fj@>5$Oi@0VF)BsSE7O{Q0UJLXgJ^%Aq^SuIiQ3jkb`lYAx%b+ctjW z`;uQj_A~izB?N$q*h$P*!UO{EL!nm$LI6YwG)}Ohj!7kj@VKND_A=cJD6Po&Hed?7 zp>k2Ei;9+EeM1iKUK0+lVd_^!n>QDIHMjSlr8EB7JK#Uqzw&Xvu%CTk(J|avcukQ_ zg>*k<8z`bOw8=b$S*Qw0Z#UqPLdfp~?2%8c+*{gq{*ksn`c@6_fmLHqU?&&So@HJF zAvPjw1bP(~YX}__>4@3|JQ4CICKwl4b_!+LAxDR@>;OiK6NrH+r$}^Vs?(%D9aZ53 zsqjQ7_CG}?LQcuYxh!*7<|Fj}wdbIJ0NDs^wh!3|G{Uc3Icp5-m!8B*(L)^oR}_6h z0uipigz)|Gtg$Zapdquy3>e2J)tglE|Ja1>5LDJg29c}AnVX8uRBp3k5zS0T34O4@ zm!DuXd9zUeU~G%a1VanV9~c%UDnX5>(9oR5?1tA|-(y54e}=PnG#~}ZPESA~2UQCyt(fjDGC(i`a=^;Wz=5Qvm&PT9 z|J7AVBE32#F(mMi%}(|@cWm4|C&-BHAw;o7NJ zM95kHzn*L`So$ViWifOvU%ouzpE02W<&OVj>cZo0_NXa}Ag zJI_5w*`R1sY2oYnEx5?|oS9;!fnW&16jLzZ=II>)ZTlQ{)9YRA;LIZ(?`n56v73sz z7SALpsVv5rjp90FCKEUtmMT(hS_uG$jPJ`9QwRJEfE}l#=6j(6G3P1>DT>JL%^2t*DO2hMY-zo0Q5Odzs49ck1b&MFi(k&5oBV1tqd7HPgH z$dbLW0KIYZL~meN;0*wahPYwU?Rn|sfs$8x)bqR^k`J0a;Z4Jj!%$obxL(#uQ3dkI zO&KG%pou|GqjXDn-9THgNn7^4SSRmA#M?%-^YAJG zG5|^y*`{!9Y=>od8Gi~D-)=N!l>{&ZDh31GSxJ1#&cz!=+oPMF9>3$Dpn%pgJX#yj z3CI@8j%2`PLXQFui9Xoi{jE^j(6g05=0mHlpvfIN*FJJh`^e5vZ8fXTVwKw4nNL4< z@G;UkAxu;U*4u1&XjVrCu*#~&bQh51EUMp^>rJp)JZY$0=2q15p^`()HH%fX00In^ z?*=OtWjzZCaQBddY>@U;KqN9JCnt~-$j_r9Hc`<^K8X;eSyGXtt~kL3j=BsuDrn~b zUtFeC40G4;@NZbrZ~FR<7p(GERcS5N)vtfen!SPDnVC9*`u1mYTE1S0+U)sp;Ol|E zrQcmRZNHoJJvkJJ`~kPyo$1bWrn+2IT7)=+jA5B5zGTW{VmWg=gIMM&emc1R#C1=+*1t~M z!3^cMlq`AW%yk9Jo*!JdyJzYCO{cfM(!;db0sq_{?Zlb36+X~P-i5`WmB!Q|Fd%K< zPDFG?X`KQm2bI>zB<}*CS%~_xq@*MV;ktsE^k5|^S`ILh0|QSBE^9u#Wfq&)|8#%< z(}%O0*x=|@+s2w{%L?}ESLfG81UKceO}KL&3P7HVR1)JPc&Gw00~qJouw(#%TvQOq z2l7&`Hzx@NM*#38gd@)KFtND>o~?AOUo#~#G>|ZxR@|+qLh+-*iT2Bx<^)={fFa7whAY{Z$`i~sv7691YIK!!ETe4HY{stP5D#$8 zQ!xiJv!P#^AuG%j0tWj|Dy_(jgr+*A|BqLqyYlnUQ&7#I>`@IV8BBQ+Ne-3La)$z_ zuVP7$_ZoSr9vXF!#V72zEe}m=cVAn0E?xyfcy86WrE^ zFOj{ivQD#^T)c3q+08by4Q_A?R2F(FrxYyM7eJqi0>Q^bTBfg}ot3n=Yafhxz3+Lw z_Evj%R!M%wv{shQYSypU-df*Zc&Vu9QXxY=c(C4+NS*`4iu%<&SV4D z)9h(>t75?F4sSJ<6HCIjL^yO3?!cliTseK>2f`3mGo%P2-rgMeq&<{oPOe8sk`bHo zP1I~*ry6L)c)=9YHw1C{9&&VFpP9L(*WUeJ?QUQGuG(&U`RwM|Wsar1&$Za=UF`af zxmtJbY}GD&=h9**`}mINEkxetBK|mL9{d%ezyII$I}mKbqMo9)GyXtJ%L6To>?3x( z*p1aEs;f^tY`2fv?X>b??CwNAA=4pZ7h)4j5mo)_p){wjqMJ57#jYaMt2pC6V3m0#7G1;>4-CFAmm_Ji7cidG@HlOD3T>^dyuwv@ajjE`XXs zs7_i=;vAuH}?u;rwU{Rw*+ zZ8{aIi4you6#PfQJ8Eo6%Yj*{$YKtWjWD$;IPU);hcEen$N|i}(S7)jK3LuA)m=Ar zuU_4K6ZU}B4)9&Ev-~5`Ll_RkRdY#?g64!Ta&99V0?N~6SVm&h&@i>H9k}+|*RJIs zH8(dS3vg(>wRntHG(}x2eRp!0r~wrX{Nh@=64zbLU7&)Zml=Wv8#(`E1aqr{;+NtI z+|F&<)8wDUmtM7$a4#oa=MmSXP^CE3EGQ+ZvCAu6Ewq>Z|yvg$r*3ZW+@q@Q9!$QRx%Z!107Nk}^e5#Vi2@+>moPb{&o(orG-7 zL7FX#Gb7XJFYskf`NQ(#BvEK0h-V;eN-l$y6*V{1G$pkpHSueTi@Pc;7E**_VeSVF zuL$YTET{;R8UeQs(!|I?41$14gHy=}#h8)Y#n*(~)#WW^KFG0`wtQUPzL~!yKYvM` z)f%)~dHt}*GwdlfmzwcH58TU_;O>9H-Dik<1LaBdB_m_|`-Xv2_><`_44wkopi;Xx z^3?Ph4M$4(t&Jt64W(%FAiqcSsyd#1@PnMqU|s+dW4eau9D0MrHt^}V_WcR&ra6_M zv=W=9O|U5(R=(ZXQK*sc!ahPv#g(!A*xA^Jxblbj+awrtx4*Ic<+9aV8bRwfv7Fdh z#GGx)2J!r&G7alpiUd)Qb3qod1Qs834zvUGJ>`C=gESf{SRph1XJWr8e+9%ph=-pD zOIUn}OmUuozXghr=~KCd=d>mi<)72H>|992_~?<6SQ9ABfq%SAQ0OWZ-~{N7LLN^L0icii=F;5a?|IiR~Ww4s)LzysPhSQKKd z!2Z#WFQD^Nm0WPdnJb*1+HwKsUl{BSvHzy?6XlLg*nNp|)d=_Q40@h2LMnKKxx}#t z7#h`&)d3Cf6wbS!aPZ_YV2@b94JzBiX2*~5ut3ez@$&R*cd+-@%DBpzYxdn z#cuNPV{uyUn?#EXz$+(l?ax?f{JeVv2Bpe&vHRo4fOYo)#vMDGGgJH+cG-RtvVwLx zr+NbUY)jY{%wU!6WQXY(M(1ekrPx`uaH7u)A(>%5cC>|cq0_Wq4?05p74+KJyamet E0N2upt^fc4 literal 0 HcmV?d00001 diff --git a/src/svg/puretext/mod.rs b/src/svg/puretext/mod.rs index 5cd337f..6f337bf 100644 --- a/src/svg/puretext/mod.rs +++ b/src/svg/puretext/mod.rs @@ -1,6 +1,7 @@ use serde_json::Value; pub mod constants; +pub mod font; pub mod parsers; pub mod render; diff --git a/src/svg/puretext/render.rs b/src/svg/puretext/render.rs index dd0625e..d0b409a 100644 --- a/src/svg/puretext/render.rs +++ b/src/svg/puretext/render.rs @@ -1,4 +1,5 @@ use crate::svg::{ + puretext::font::path::pathify_svg_texts, puretext::parsers::{ParsedStyleAlignment, TextItem, TextParserResult}, DEFAULT_SIZE, }; @@ -113,34 +114,17 @@ fn generate_svg(elements: &[RenderElement], bg_color: &str) -> String { let width = DEFAULT_SIZE; let padding_x = 20; let padding_y = 30; - let line_height = 27; + let line_height = 28; let font_size = 36; let mut svg_content = String::new(); - let mut current_y = padding_y + line_height; // Start after top padding - let mut used_font_weights = std::collections::HashSet::new(); + let mut current_y = padding_y; // Start after top padding // Calculate dynamic height based on content with minimum of 500 - let calculated_height = elements.len() * line_height + padding_y + 10; + let calculated_height = elements.len() * line_height + padding_y; let height = std::cmp::max(calculated_height as u32, DEFAULT_SIZE); - // First pass: collect used font weights - for element in elements { - if element.element_type == "p" { - let font_weight_to_use = if let Some(font_weight) = &element.props.style.font_weight { - if font_weight == "bold" { - "700" - } else { - "400" - } - } else { - "400" - }; - used_font_weights.insert(font_weight_to_use.to_string()); - } - } - - // Second pass: generate SVG content + // Generate SVG content for element in elements { if element.element_type == "p" { if !element.props.children.is_empty() { @@ -163,7 +147,7 @@ fn generate_svg(elements: &[RenderElement], bg_color: &str) -> String { }; let text_element = format!( - r#"{}"#, + r#"{}"#, padding_x, current_y, font_weight_to_use, @@ -174,7 +158,6 @@ fn generate_svg(elements: &[RenderElement], bg_color: &str) -> String { ); svg_content.push_str(&text_element); - svg_content.push('\n'); } } current_y += line_height; @@ -182,7 +165,7 @@ fn generate_svg(elements: &[RenderElement], bg_color: &str) -> String { } // Create font definitions with only used weights - let font_defs = create_font_definitions(&used_font_weights); + // let font_defs = create_font_definitions(&used_font_weights); // Create gradient definition if bg_color is a gradient let (background_rect, gradient_defs) = if bg_color.starts_with("linear-gradient") { @@ -197,10 +180,13 @@ fn generate_svg(elements: &[RenderElement], bg_color: &str) -> String { }; // Create the complete SVG - format!( - r#"{}{}{}{}"#, - width, height, font_defs, gradient_defs, background_rect, svg_content - ) + let svg_with_text = format!( + r#"{}{}{}"#, + width, height, gradient_defs, background_rect, svg_content + ); + + // Convert text elements to paths to avoid font loading issues + pathify_svg_texts(&svg_with_text) } fn create_gradient_background(gradient_css: &str, width: u32, height: u32) -> (String, String) { @@ -243,46 +229,6 @@ fn create_gradient_background(gradient_css: &str, width: u32, height: u32) -> (S (background_rect, gradient_defs) } -fn create_font_definitions(_used_font_weights: &std::collections::HashSet) -> String { - // Include the complete Google Fonts CSS for Turret Road font - // This is the actual CSS fetched from: https://fonts.googleapis.com/css2?family=Turret+Road:wght@200;300;400;500;700;800&display=swap - let mut font_defs = String::from(r#""#, - ); - - font_defs -} - fn calculate_gradient_coordinates(angle: f64) -> (String, String, String, String) { // For CSS linear-gradient, 0deg points up, 90deg points right // We need to calculate the gradient line that goes through the rectangle at the given angle From be98e338fe11253f395e77ce44bd6cde24ab9db8 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Thu, 11 Sep 2025 17:14:05 +0800 Subject: [PATCH 18/26] feat: RESTful Api --- Cargo.lock | 92 +++++++++++++++++++++++++ Cargo.toml | 3 +- src/client.rs | 1 + src/decoder/mod.rs | 1 + src/main.rs | 58 ++++++++++++++-- src/server.rs | 168 +++++++++++++++++++++++++++++++++++++++++++++ src/svg/mod.rs | 1 + src/types.rs | 4 ++ 8 files changed, 320 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1871a15..da15942 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,6 +81,73 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tower 0.5.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "backtrace" version = "0.3.71" @@ -765,6 +832,7 @@ dependencies = [ name = "dob-decoder-server" version = "0.1.0" dependencies = [ + "axum", "base64 0.22.1", "chrono", "ckb-hash", @@ -1683,6 +1751,12 @@ dependencies = [ "regex-automata 0.1.10", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.7.2" @@ -2462,6 +2536,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.17" @@ -2602,6 +2682,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.5" @@ -3085,8 +3175,10 @@ dependencies = [ "futures-util", "pin-project-lite", "sync_wrapper 1.0.2", + "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index be5928f..e13c120 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,8 +34,9 @@ tower = { version = "0.5", optional = true } toml = { version = "0.8.2", optional = true } tokio = { version = "1.37", features = ["rt", "signal"], optional = true } tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"], optional = true } +axum = { version = "0.7", features = ["macros"], optional = true } [features] default = ["standalone_server", "render_debug"] -standalone_server = ["jsonrpsee", "toml", "tokio", "tracing-subscriber", "tower-http", "tower"] +standalone_server = ["jsonrpsee", "toml", "tokio", "tracing-subscriber", "tower-http", "tower", "axum"] render_debug = [] diff --git a/src/client.rs b/src/client.rs index adb186b..a38e6e6 100644 --- a/src/client.rs +++ b/src/client.rs @@ -155,6 +155,7 @@ impl RpcClient { } } +#[derive(Clone)] pub struct ImageFetchClient { base_url: HashMap, client: Client, diff --git a/src/decoder/mod.rs b/src/decoder/mod.rs index ee0ab0a..1ef83f9 100644 --- a/src/decoder/mod.rs +++ b/src/decoder/mod.rs @@ -11,6 +11,7 @@ use crate::{ pub(crate) mod helpers; use helpers::*; +#[derive(Clone)] pub struct DOBDecoder { rpc: RpcClient, settings: Settings, diff --git a/src/main.rs b/src/main.rs index 0cbdbb3..57b0f30 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,7 +43,11 @@ async fn main() { let cache_expiration = settings.dobs_cache_expiration_sec; let decoder = decoder::DOBDecoder::new(settings); - tracing::info!("running decoder server at {}", rpc_server_address); + // Create the decoder server instance + let decoder_server = server::DecoderStandaloneServer::new(decoder, cache_expiration); + + // Start JSON-RPC server + tracing::info!("running JSON-RPC decoder server at {}", rpc_server_address); let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) @@ -51,14 +55,54 @@ async fn main() { let http_middleware = tower::ServiceBuilder::new().layer(cors); let http_server = Server::builder() .set_http_middleware(http_middleware) - .build(rpc_server_address) + .build(rpc_server_address.clone()) .await .expect("build http_server"); - let rpc_methods = server::DecoderStandaloneServer::new(decoder, cache_expiration); - let handler = http_server.start(rpc_methods.into_rpc()); + let rpc_handler = http_server.start(decoder_server.clone().into_rpc()); + + // Start RESTful API server + #[cfg(feature = "axum")] + { + let restful_address = "127.0.0.1:8090"; + tracing::info!("running RESTful API server at {}", restful_address); + + let app = + server::DecoderStandaloneServer::create_restful_routes().with_state(decoder_server); + + let restful_listener = tokio::net::TcpListener::bind(restful_address) + .await + .expect("Failed to bind RESTful server"); + + let restful_handle = tokio::spawn(async move { + axum::serve(restful_listener, app) + .await + .expect("RESTful server failed"); + }); + + tracing::info!("Both JSON-RPC and RESTful API servers are running"); + tracing::info!("JSON-RPC server: {}", rpc_server_address); + tracing::info!("RESTful API server: {}", restful_address); + tracing::info!("Example RESTful endpoints:"); + tracing::info!( + " GET http://{}/dob_decode_svg/0x", + restful_address + ); + tracing::info!(" GET http://{}/dob_decode/0x", restful_address); + tracing::info!(" GET http://{}/protocol_versions", restful_address); + + tokio::signal::ctrl_c().await.unwrap(); + tracing::info!("stopping both servers"); + + restful_handle.abort(); + rpc_handler.stop().unwrap(); + } - tokio::signal::ctrl_c().await.unwrap(); - tracing::info!("stopping decoder server"); - handler.stop().unwrap(); + #[cfg(not(feature = "axum"))] + { + tracing::info!("RESTful API not available (axum feature not enabled)"); + tokio::signal::ctrl_c().await.unwrap(); + tracing::info!("stopping JSON-RPC server"); + rpc_handler.stop().unwrap(); + } } diff --git a/src/server.rs b/src/server.rs index a4b1775..73a00c0 100644 --- a/src/server.rs +++ b/src/server.rs @@ -8,6 +8,15 @@ use jsonrpsee::{proc_macros::rpc, tracing, types::error::ErrorObjectOwned}; use serde::{Deserialize, Serialize}; use serde_json::Value; +#[cfg(feature = "axum")] +use axum::{ + extract::Path, + http::StatusCode, + response::{Html, IntoResponse}, + routing::get, + Router, +}; + use crate::decoder::helpers::{decode_cluster_data, decode_spore_data}; use crate::decoder::DOBDecoder; use crate::svg::DOBSvgExtractor; @@ -52,6 +61,7 @@ trait DecoderRpc { ) -> Result; } +#[derive(Clone)] pub struct DecoderStandaloneServer { decoder: DOBDecoder, cache_expiration: u64, @@ -250,3 +260,161 @@ fn now() -> Result { .duration_since(UNIX_EPOCH) .map_err(|_| Error::SystemTimeError) } + +// RESTful API implementation +#[cfg(feature = "axum")] +impl DecoderStandaloneServer { + /// Create RESTful API routes + pub fn create_restful_routes() -> Router { + Router::new() + .route("/dob_decode_svg/:spore_id", get(handle_dob_decode_svg)) + .route("/dob_decode/:spore_id", get(handle_dob_decode)) + .route("/dob_batch_decode/:spore_ids", get(handle_dob_batch_decode)) + .route( + "/dob_raw_decode/:spore_data/:cluster_data", + get(handle_dob_raw_decode), + ) + .route( + "/dob_extract_image_from_fsuri/:fsuri", + get(handle_extract_image_from_fsuri), + ) + .route("/protocol_versions", get(handle_protocol_versions)) + } +} + +/// Handle dob_decode_svg RESTful endpoint +#[cfg(feature = "axum")] +async fn handle_dob_decode_svg( + Path(spore_id): Path, + axum::extract::State(server): axum::extract::State, +) -> impl IntoResponse { + tracing::info!("RESTful API: decoding SVG for spore_id {}", spore_id); + + match server.decode_svg(spore_id).await { + Ok(svg_content) => { + tracing::info!("RESTful API: SVG decoded successfully"); + (StatusCode::OK, Html(svg_content)) + } + Err(error) => { + tracing::error!("RESTful API: SVG decode failed: {}", error); + ( + StatusCode::BAD_REQUEST, + Html(format!("Error: {}", error.message())), + ) + } + } +} + +/// Handle dob_decode RESTful endpoint +#[cfg(feature = "axum")] +async fn handle_dob_decode( + Path(spore_id): Path, + axum::extract::State(server): axum::extract::State, +) -> impl IntoResponse { + tracing::info!("RESTful API: decoding spore_id {}", spore_id); + + match server.decode(spore_id).await { + Ok(result) => { + tracing::info!("RESTful API: decode successful"); + (StatusCode::OK, Html(result)) + } + Err(error) => { + tracing::error!("RESTful API: decode failed: {}", error); + ( + StatusCode::BAD_REQUEST, + Html(format!("Error: {}", error.message())), + ) + } + } +} + +/// Handle dob_batch_decode RESTful endpoint +#[cfg(feature = "axum")] +async fn handle_dob_batch_decode( + Path(spore_ids): Path, + axum::extract::State(server): axum::extract::State, +) -> impl IntoResponse { + tracing::info!("RESTful API: batch decoding spore_ids: {}", spore_ids); + + // Parse comma-separated spore IDs + let spore_id_list: Vec = spore_ids.split(',').map(|s| s.trim().to_string()).collect(); + + match server.batch_decode(spore_id_list).await { + Ok(results) => { + tracing::info!("RESTful API: batch decode successful"); + let json_result = serde_json::to_string(&results).unwrap_or_else(|_| "[]".to_string()); + (StatusCode::OK, Html(json_result)) + } + Err(error) => { + tracing::error!("RESTful API: batch decode failed: {}", error); + ( + StatusCode::BAD_REQUEST, + Html(format!("Error: {}", error.message())), + ) + } + } +} + +/// Handle dob_raw_decode RESTful endpoint +#[cfg(feature = "axum")] +async fn handle_dob_raw_decode( + Path((spore_data, cluster_data)): Path<(String, String)>, + axum::extract::State(server): axum::extract::State, +) -> impl IntoResponse { + tracing::info!( + "RESTful API: raw decoding spore_data: {}, cluster_data: {}", + spore_data, + cluster_data + ); + + match server.raw_decode(spore_data, cluster_data).await { + Ok(result) => { + tracing::info!("RESTful API: raw decode successful"); + (StatusCode::OK, Html(result)) + } + Err(error) => { + tracing::error!("RESTful API: raw decode failed: {}", error); + ( + StatusCode::BAD_REQUEST, + Html(format!("Error: {}", error.message())), + ) + } + } +} + +/// Handle dob_extract_image_from_fsuri RESTful endpoint +#[cfg(feature = "axum")] +async fn handle_extract_image_from_fsuri( + Path(fsuri): Path, + axum::extract::State(server): axum::extract::State, +) -> impl IntoResponse { + tracing::info!("RESTful API: extracting image from fsuri: {}", fsuri); + + match server.extract_image_from_fsuri(fsuri, None).await { + Ok(result) => { + tracing::info!("RESTful API: image extraction successful"); + (StatusCode::OK, Html(result)) + } + Err(error) => { + tracing::error!("RESTful API: image extraction failed: {}", error); + ( + StatusCode::BAD_REQUEST, + Html(format!("Error: {}", error.message())), + ) + } + } +} + +/// Handle protocol_versions RESTful endpoint +#[cfg(feature = "axum")] +async fn handle_protocol_versions( + axum::extract::State(server): axum::extract::State, +) -> impl IntoResponse { + tracing::info!("RESTful API: getting protocol versions"); + + let versions = server.protocol_versions().await; + let json_result = serde_json::to_string(&versions).unwrap_or_else(|_| "[]".to_string()); + + tracing::info!("RESTful API: protocol versions retrieved successfully"); + (StatusCode::OK, Html(json_result)) +} diff --git a/src/svg/mod.rs b/src/svg/mod.rs index f5dbe7e..00c851a 100644 --- a/src/svg/mod.rs +++ b/src/svg/mod.rs @@ -82,6 +82,7 @@ pub fn detect_image_mime_type(hex_content: String) -> Option<&'static str> { None } +#[derive(Clone)] pub struct DOBSvgExtractor { fetcher: ImageFetchClient, } diff --git a/src/types.rs b/src/types.rs index 3c2db3f..9cbf073 100644 --- a/src/types.rs +++ b/src/types.rs @@ -235,6 +235,7 @@ pub struct DOBDecoderFormat { // asscoiate `code_hash` of decoder binary with its onchain deployment information #[cfg_attr(feature = "standalone_server", derive(Serialize, Deserialize))] #[cfg_attr(test, derive(Default))] +#[derive(Clone)] pub struct OnchainDecoderDeployment { pub code_hash: H256, pub tx_hash: H256, @@ -243,6 +244,7 @@ pub struct OnchainDecoderDeployment { #[cfg_attr(feature = "standalone_server", derive(Serialize, Deserialize))] #[cfg_attr(test, derive(Default))] +#[derive(Clone)] pub enum HashType { #[serde(rename(serialize = "data", deserialize = "data"))] #[cfg_attr(test, default)] @@ -268,6 +270,7 @@ impl From<&HashType> for ScriptHashType { #[cfg_attr(feature = "standalone_server", derive(Serialize, Deserialize))] #[cfg_attr(test, derive(Default))] +#[derive(Clone)] pub struct ScriptId { pub code_hash: H256, pub hash_type: HashType, @@ -276,6 +279,7 @@ pub struct ScriptId { // standalone server settings in TOML format #[cfg_attr(feature = "standalone_server", derive(Serialize, Deserialize))] #[cfg_attr(test, derive(Default))] +#[derive(Clone)] pub struct Settings { pub protocol_versions: Vec, pub ckb_rpc: String, From f1fea149ffaea429ba9d8ae6490f1d46bb76c88a Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Thu, 11 Sep 2025 17:53:59 +0800 Subject: [PATCH 19/26] feat: enable RESTful api --- Cargo.toml | 4 +- settings.toml | 3 + src/main.rs | 39 ++-- src/server.rs | 420 ------------------------------------------ src/server/jsonrpc.rs | 88 +++++++++ src/server/mod.rs | 7 + src/server/restful.rs | 161 ++++++++++++++++ src/server/server.rs | 237 ++++++++++++++++++++++++ src/server/utils.rs | 69 +++++++ src/types.rs | 1 + 10 files changed, 580 insertions(+), 449 deletions(-) delete mode 100644 src/server.rs create mode 100644 src/server/jsonrpc.rs create mode 100644 src/server/mod.rs create mode 100644 src/server/restful.rs create mode 100644 src/server/server.rs create mode 100644 src/server/utils.rs diff --git a/Cargo.toml b/Cargo.toml index e13c120..f5cb883 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ lazy_static = "1.4" lazy-regex = "3.1.0" chrono = { version = "0.4", features = ["serde"] } ckb-vm = { version = "0.24", features = ["asm"] } +axum = { version = "0.7", features = ["macros"] } spore-types = { git = "https://github.com/sporeprotocol/spore-contract", rev = "81315ca" } @@ -34,9 +35,8 @@ tower = { version = "0.5", optional = true } toml = { version = "0.8.2", optional = true } tokio = { version = "1.37", features = ["rt", "signal"], optional = true } tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"], optional = true } -axum = { version = "0.7", features = ["macros"], optional = true } [features] default = ["standalone_server", "render_debug"] -standalone_server = ["jsonrpsee", "toml", "tokio", "tracing-subscriber", "tower-http", "tower", "axum"] +standalone_server = ["jsonrpsee", "toml", "tokio", "tracing-subscriber", "tower-http", "tower"] render_debug = [] diff --git a/settings.toml b/settings.toml index d3120ac..8d20400 100644 --- a/settings.toml +++ b/settings.toml @@ -13,6 +13,9 @@ image_fetcher_url = { btcfs = "https://mempool.space/api/tx/", ipfs = "https://i # address that rpc server running at in case of standalone server mode rpc_server_address = "0.0.0.0:8090" +# address that restful api server running at +restful_server_address = "0.0.0.0:8091" + # directory that stores decoders on hard-disk, including on-chain and off-chain binary files decoders_cache_directory = "cache/decoders/testnet" diff --git a/src/main.rs b/src/main.rs index 57b0f30..a087593 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,6 +40,7 @@ async fn main() { tracing::info!("DOBs cache directory: {:?}", settings.dobs_cache_directory); let rpc_server_address = settings.rpc_server_address.clone(); + let restful_server_address = settings.restful_server_address.clone(); let cache_expiration = settings.dobs_cache_expiration_sec; let decoder = decoder::DOBDecoder::new(settings); @@ -62,15 +63,12 @@ async fn main() { let rpc_handler = http_server.start(decoder_server.clone().into_rpc()); // Start RESTful API server - #[cfg(feature = "axum")] - { - let restful_address = "127.0.0.1:8090"; - tracing::info!("running RESTful API server at {}", restful_address); - + let restful_handle = if let Some(restful_server_address) = restful_server_address { + tracing::info!("running RESTful API server at {}", restful_server_address); let app = server::DecoderStandaloneServer::create_restful_routes().with_state(decoder_server); - let restful_listener = tokio::net::TcpListener::bind(restful_address) + let restful_listener = tokio::net::TcpListener::bind(&restful_server_address) .await .expect("Failed to bind RESTful server"); @@ -80,29 +78,16 @@ async fn main() { .expect("RESTful server failed"); }); - tracing::info!("Both JSON-RPC and RESTful API servers are running"); - tracing::info!("JSON-RPC server: {}", rpc_server_address); - tracing::info!("RESTful API server: {}", restful_address); - tracing::info!("Example RESTful endpoints:"); - tracing::info!( - " GET http://{}/dob_decode_svg/0x", - restful_address - ); - tracing::info!(" GET http://{}/dob_decode/0x", restful_address); - tracing::info!(" GET http://{}/protocol_versions", restful_address); + Some(restful_handle) + } else { + None + }; - tokio::signal::ctrl_c().await.unwrap(); - tracing::info!("stopping both servers"); + tokio::signal::ctrl_c().await.unwrap(); + tracing::info!("stopping both servers"); + rpc_handler.stop().unwrap(); + if let Some(restful_handle) = restful_handle { restful_handle.abort(); - rpc_handler.stop().unwrap(); - } - - #[cfg(not(feature = "axum"))] - { - tracing::info!("RESTful API not available (axum feature not enabled)"); - tokio::signal::ctrl_c().await.unwrap(); - tracing::info!("stopping JSON-RPC server"); - rpc_handler.stop().unwrap(); } } diff --git a/src/server.rs b/src/server.rs deleted file mode 100644 index 73a00c0..0000000 --- a/src/server.rs +++ /dev/null @@ -1,420 +0,0 @@ -use std::fs; -use std::path::PathBuf; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use base64::{engine::general_purpose::STANDARD, Engine}; -use jsonrpsee::core::async_trait; -use jsonrpsee::{proc_macros::rpc, tracing, types::error::ErrorObjectOwned}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[cfg(feature = "axum")] -use axum::{ - extract::Path, - http::StatusCode, - response::{Html, IntoResponse}, - routing::get, - Router, -}; - -use crate::decoder::helpers::{decode_cluster_data, decode_spore_data}; -use crate::decoder::DOBDecoder; -use crate::svg::DOBSvgExtractor; -use crate::types::Error; - -// decoding result contains rendered result from native decoder and DNA string for optional use -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct ServerDecodeResult { - render_output: String, - dob_content: Value, -} - -#[rpc(server)] -trait DecoderRpc { - #[method(name = "dob_protocol_version")] - async fn protocol_versions(&self) -> Vec; - - #[method(name = "dob_decode")] - async fn decode(&self, hexed_spore_id: String) -> Result; - - #[method(name = "dob_batch_decode")] - async fn batch_decode( - &self, - hexed_spore_ids: Vec, - ) -> Result, ErrorObjectOwned>; - - #[method(name = "dob_raw_decode")] - async fn raw_decode( - &self, - spore_data: String, - cluster_data: String, - ) -> Result; - - #[method(name = "dob_decode_svg")] - async fn decode_svg(&self, hexed_spore_id: String) -> Result; - - #[method(name = "dob_extract_image_from_fsuri")] - async fn extract_image_from_fsuri( - &self, - fsuri: String, - encode_type: Option, - ) -> Result; -} - -#[derive(Clone)] -pub struct DecoderStandaloneServer { - decoder: DOBDecoder, - cache_expiration: u64, - svg_extractor: DOBSvgExtractor, -} - -impl DecoderStandaloneServer { - pub fn new(decoder: DOBDecoder, cache_expiration: u64) -> Self { - Self { - svg_extractor: DOBSvgExtractor::new(&decoder.setting().image_fetcher_url), - decoder, - cache_expiration, - } - } - - async fn cache_decode( - &self, - spore_id: [u8; 32], - cache_path: PathBuf, - ) -> Result<(String, Value), Error> { - let (content, dna, metadata) = self.decoder.fetch_decode_ingredients(spore_id).await?; - let render_output = self.decoder.decode_dna(&dna, metadata).await?; - write_dob_to_cache(&render_output, &content, cache_path, self.cache_expiration)?; - Ok((render_output, content)) - } -} - -#[async_trait] -impl DecoderRpcServer for DecoderStandaloneServer { - async fn protocol_versions(&self) -> Vec { - self.decoder.protocol_versions() - } - - // decode DNA in particular spore DOB cell - async fn decode(&self, hexed_spore_id: String) -> Result { - tracing::info!("decoding spore_id {hexed_spore_id}"); - let spore_id: [u8; 32] = hex::decode(trim_0x(&hexed_spore_id)) - .map_err(|_| Error::HexedSporeIdParseError)? - .try_into() - .map_err(|_| Error::SporeIdLengthInvalid)?; - let mut cache_path = self.decoder.setting().dobs_cache_directory.clone(); - cache_path.push(format!("{}.dob", hex::encode(spore_id))); - let (render_output, dob_content) = - if let Some(cache) = read_dob_from_cache(cache_path.clone(), self.cache_expiration)? { - cache - } else { - self.cache_decode(spore_id, cache_path).await? - }; - let result = serde_json::to_string(&ServerDecodeResult { - render_output, - dob_content, - }) - .unwrap(); - tracing::info!("spore_id {hexed_spore_id}, result: {result}"); - Ok(result) - } - - // decode DNA from a set - async fn batch_decode( - &self, - hexed_spore_ids: Vec, - ) -> Result, ErrorObjectOwned> { - let mut await_results = Vec::new(); - for hexed_spore_id in hexed_spore_ids { - await_results.push(self.decode(hexed_spore_id)); - } - let results = futures::future::join_all(await_results) - .await - .into_iter() - .map(|result| match result { - Ok(result) => result, - Err(error) => format!("server error: {error}"), - }) - .collect(); - Ok(results) - } - - // decode directly from spore and cluster data - async fn raw_decode( - &self, - hexed_spore_data: String, - hexed_cluster_data: String, - ) -> Result { - let spore_data = - hex::decode(trim_0x(&hexed_spore_data)).map_err(|_| Error::SporeDataUncompatible)?; - let cluster_data = hex::decode(trim_0x(&hexed_cluster_data)) - .map_err(|_| Error::ClusterDataUncompatible)?; - let dob = decode_spore_data(&spore_data)?; - let dob_metadata = decode_cluster_data(&cluster_data)?; - let render_output = self.decoder.decode_dna(&dob.dna, dob_metadata).await?; - let result = serde_json::to_string(&ServerDecodeResult { - render_output, - dob_content: dob.content, - }) - .unwrap(); - tracing::info!("raw, result: {result}"); - Ok(result) - } - - async fn decode_svg(&self, hexed_spore_id: String) -> Result { - let ServerDecodeResult { - render_output, - dob_content: _, - } = serde_json::from_str(&self.decode(hexed_spore_id).await?) - .map_err(|e| ErrorObjectOwned::owned(-1, e.to_string(), None::<()>))?; - let svg = self.svg_extractor.extract_svg(render_output).await?; - Ok(svg) - } - - async fn extract_image_from_fsuri( - &self, - fsuri: String, - encode_type: Option, - ) -> Result { - let raw_images = self - .svg_extractor - .get_fetcher() - .fetch_images(&[fsuri]) - .await?; - let image = raw_images.first().ok_or(Error::NoImageFound)?; - - match encode_type.as_deref() { - Some("hex") | None => Ok(hex::encode(image)), - Some("base64") => Ok(STANDARD.encode(image)), - unknown => Err(ErrorObjectOwned::owned::( - -1, - format!( - "Unknown encode type: {}. Supported types: 'base64', 'hex' (default)", - unknown.unwrap_or("unknown") - ), - None, - )), - } - } -} - -fn trim_0x(hexed: &str) -> &str { - hexed.trim_start_matches("0x") -} - -fn read_dob_from_cache( - cache_path: PathBuf, - mut expiration: u64, -) -> Result, Error> { - if !cache_path.exists() { - return Ok(None); - } - let file_content = fs::read_to_string(&cache_path) - .map_err(|_| Error::DOBRenderCacheNotFound(cache_path.clone()))?; - let mut lines = file_content.split('\n'); - let (Some(result), Some(content), timestamp) = (lines.next(), lines.next(), lines.next()) - else { - return Err(Error::DOBRenderCacheModified(cache_path)); - }; - if let Some(value) = timestamp { - if !value.is_empty() { - expiration = value - .parse::() - .map_err(|_| Error::DOBRenderCacheModified(cache_path.clone()))?; - } - } - match serde_json::from_str(content) { - Ok(content) => { - if expiration > 0 && now()? > Duration::from_secs(expiration) { - Ok(None) - } else { - Ok(Some((result.to_string(), content))) - } - } - Err(_) => Err(Error::DOBRenderCacheModified(cache_path)), - } -} - -fn write_dob_to_cache( - render_result: &str, - dob_content: &Value, - cache_path: PathBuf, - cache_expiration: u64, -) -> Result<(), Error> { - let expiration_timestamp = if cache_expiration > 0 { - now()? - .checked_add(Duration::from_secs(cache_expiration)) - .ok_or(Error::SystemTimeError)? - .as_secs() - } else { - 0 // zero means always read from cache - }; - let json_dob_content = serde_json::to_string(dob_content).unwrap(); - let file_content = format!("{render_result}\n{json_dob_content}\n{expiration_timestamp}"); - fs::write(&cache_path, file_content).map_err(|_| Error::DOBRenderCacheNotFound(cache_path))?; - Ok(()) -} - -fn now() -> Result { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| Error::SystemTimeError) -} - -// RESTful API implementation -#[cfg(feature = "axum")] -impl DecoderStandaloneServer { - /// Create RESTful API routes - pub fn create_restful_routes() -> Router { - Router::new() - .route("/dob_decode_svg/:spore_id", get(handle_dob_decode_svg)) - .route("/dob_decode/:spore_id", get(handle_dob_decode)) - .route("/dob_batch_decode/:spore_ids", get(handle_dob_batch_decode)) - .route( - "/dob_raw_decode/:spore_data/:cluster_data", - get(handle_dob_raw_decode), - ) - .route( - "/dob_extract_image_from_fsuri/:fsuri", - get(handle_extract_image_from_fsuri), - ) - .route("/protocol_versions", get(handle_protocol_versions)) - } -} - -/// Handle dob_decode_svg RESTful endpoint -#[cfg(feature = "axum")] -async fn handle_dob_decode_svg( - Path(spore_id): Path, - axum::extract::State(server): axum::extract::State, -) -> impl IntoResponse { - tracing::info!("RESTful API: decoding SVG for spore_id {}", spore_id); - - match server.decode_svg(spore_id).await { - Ok(svg_content) => { - tracing::info!("RESTful API: SVG decoded successfully"); - (StatusCode::OK, Html(svg_content)) - } - Err(error) => { - tracing::error!("RESTful API: SVG decode failed: {}", error); - ( - StatusCode::BAD_REQUEST, - Html(format!("Error: {}", error.message())), - ) - } - } -} - -/// Handle dob_decode RESTful endpoint -#[cfg(feature = "axum")] -async fn handle_dob_decode( - Path(spore_id): Path, - axum::extract::State(server): axum::extract::State, -) -> impl IntoResponse { - tracing::info!("RESTful API: decoding spore_id {}", spore_id); - - match server.decode(spore_id).await { - Ok(result) => { - tracing::info!("RESTful API: decode successful"); - (StatusCode::OK, Html(result)) - } - Err(error) => { - tracing::error!("RESTful API: decode failed: {}", error); - ( - StatusCode::BAD_REQUEST, - Html(format!("Error: {}", error.message())), - ) - } - } -} - -/// Handle dob_batch_decode RESTful endpoint -#[cfg(feature = "axum")] -async fn handle_dob_batch_decode( - Path(spore_ids): Path, - axum::extract::State(server): axum::extract::State, -) -> impl IntoResponse { - tracing::info!("RESTful API: batch decoding spore_ids: {}", spore_ids); - - // Parse comma-separated spore IDs - let spore_id_list: Vec = spore_ids.split(',').map(|s| s.trim().to_string()).collect(); - - match server.batch_decode(spore_id_list).await { - Ok(results) => { - tracing::info!("RESTful API: batch decode successful"); - let json_result = serde_json::to_string(&results).unwrap_or_else(|_| "[]".to_string()); - (StatusCode::OK, Html(json_result)) - } - Err(error) => { - tracing::error!("RESTful API: batch decode failed: {}", error); - ( - StatusCode::BAD_REQUEST, - Html(format!("Error: {}", error.message())), - ) - } - } -} - -/// Handle dob_raw_decode RESTful endpoint -#[cfg(feature = "axum")] -async fn handle_dob_raw_decode( - Path((spore_data, cluster_data)): Path<(String, String)>, - axum::extract::State(server): axum::extract::State, -) -> impl IntoResponse { - tracing::info!( - "RESTful API: raw decoding spore_data: {}, cluster_data: {}", - spore_data, - cluster_data - ); - - match server.raw_decode(spore_data, cluster_data).await { - Ok(result) => { - tracing::info!("RESTful API: raw decode successful"); - (StatusCode::OK, Html(result)) - } - Err(error) => { - tracing::error!("RESTful API: raw decode failed: {}", error); - ( - StatusCode::BAD_REQUEST, - Html(format!("Error: {}", error.message())), - ) - } - } -} - -/// Handle dob_extract_image_from_fsuri RESTful endpoint -#[cfg(feature = "axum")] -async fn handle_extract_image_from_fsuri( - Path(fsuri): Path, - axum::extract::State(server): axum::extract::State, -) -> impl IntoResponse { - tracing::info!("RESTful API: extracting image from fsuri: {}", fsuri); - - match server.extract_image_from_fsuri(fsuri, None).await { - Ok(result) => { - tracing::info!("RESTful API: image extraction successful"); - (StatusCode::OK, Html(result)) - } - Err(error) => { - tracing::error!("RESTful API: image extraction failed: {}", error); - ( - StatusCode::BAD_REQUEST, - Html(format!("Error: {}", error.message())), - ) - } - } -} - -/// Handle protocol_versions RESTful endpoint -#[cfg(feature = "axum")] -async fn handle_protocol_versions( - axum::extract::State(server): axum::extract::State, -) -> impl IntoResponse { - tracing::info!("RESTful API: getting protocol versions"); - - let versions = server.protocol_versions().await; - let json_result = serde_json::to_string(&versions).unwrap_or_else(|_| "[]".to_string()); - - tracing::info!("RESTful API: protocol versions retrieved successfully"); - (StatusCode::OK, Html(json_result)) -} diff --git a/src/server/jsonrpc.rs b/src/server/jsonrpc.rs new file mode 100644 index 0000000..542c4a6 --- /dev/null +++ b/src/server/jsonrpc.rs @@ -0,0 +1,88 @@ +use jsonrpsee::core::async_trait; +use jsonrpsee::{proc_macros::rpc, types::error::ErrorObjectOwned}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::server::DecoderStandaloneServer; + +// decoding result contains rendered result from native decoder and DNA string for optional use +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ServerDecodeResult { + render_output: String, + dob_content: Value, +} + +#[rpc(server)] +trait DecoderRpc { + #[method(name = "dob_protocol_version")] + async fn protocol_versions(&self) -> Vec; + + #[method(name = "dob_decode")] + async fn decode(&self, hexed_spore_id: String) -> Result; + + #[method(name = "dob_batch_decode")] + async fn batch_decode( + &self, + hexed_spore_ids: Vec, + ) -> Result, ErrorObjectOwned>; + + #[method(name = "dob_raw_decode")] + async fn raw_decode( + &self, + spore_data: String, + cluster_data: String, + ) -> Result; + + #[method(name = "dob_decode_svg")] + async fn decode_svg(&self, hexed_spore_id: String) -> Result; + + #[method(name = "dob_extract_image_from_fsuri")] + async fn extract_image_from_fsuri( + &self, + fsuri: String, + encode_type: Option, + ) -> Result; +} + +#[async_trait] +impl DecoderRpcServer for DecoderStandaloneServer { + async fn protocol_versions(&self) -> Vec { + self.service_protocol_versions().await + } + + // decode DNA in particular spore DOB cell + async fn decode(&self, hexed_spore_id: String) -> Result { + self.service_decode(hexed_spore_id).await + } + + // decode DNA from a set + async fn batch_decode( + &self, + hexed_spore_ids: Vec, + ) -> Result, ErrorObjectOwned> { + self.service_batch_decode(hexed_spore_ids).await + } + + // decode directly from spore and cluster data + async fn raw_decode( + &self, + hexed_spore_data: String, + hexed_cluster_data: String, + ) -> Result { + self.service_raw_decode(hexed_spore_data, hexed_cluster_data) + .await + } + + async fn decode_svg(&self, hexed_spore_id: String) -> Result { + self.service_decode_svg(hexed_spore_id).await + } + + async fn extract_image_from_fsuri( + &self, + fsuri: String, + encode_type: Option, + ) -> Result { + self.service_extract_image_from_fsuri(fsuri, encode_type) + .await + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..9d3064b --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,7 @@ +mod jsonrpc; +mod restful; +mod server; +mod utils; + +pub use jsonrpc::DecoderRpcServer; +pub use server::DecoderStandaloneServer; diff --git a/src/server/restful.rs b/src/server/restful.rs new file mode 100644 index 0000000..177013f --- /dev/null +++ b/src/server/restful.rs @@ -0,0 +1,161 @@ +use axum::{ + extract::Path, + http::StatusCode, + response::{Html, IntoResponse}, + routing::get, + Router, +}; +use jsonrpsee::tracing; + +use crate::server::DecoderStandaloneServer; + +// RESTful API implementation +impl DecoderStandaloneServer { + /// Create RESTful API routes + pub fn create_restful_routes() -> Router { + Router::new() + .route("/dob_decode_svg/:spore_id", get(handle_dob_decode_svg)) + .route("/dob_decode/:spore_id", get(handle_dob_decode)) + .route("/dob_batch_decode/:spore_ids", get(handle_dob_batch_decode)) + .route( + "/dob_raw_decode/:spore_data/:cluster_data", + get(handle_dob_raw_decode), + ) + .route( + "/dob_extract_image_from_fsuri/:fsuri", + get(handle_extract_image_from_fsuri), + ) + .route("/protocol_versions", get(handle_protocol_versions)) + } +} + +/// Handle dob_decode_svg RESTful endpoint +async fn handle_dob_decode_svg( + Path(spore_id): Path, + axum::extract::State(server): axum::extract::State, +) -> impl IntoResponse { + tracing::info!("RESTful API: decoding SVG for spore_id {}", spore_id); + + match server.service_decode_svg(spore_id).await { + Ok(svg_content) => { + tracing::info!("RESTful API: SVG decoded successfully"); + (StatusCode::OK, Html(svg_content)) + } + Err(error) => { + tracing::error!("RESTful API: SVG decode failed: {}", error); + ( + StatusCode::BAD_REQUEST, + Html(format!("Error: {}", error.message())), + ) + } + } +} + +/// Handle dob_decode RESTful endpoint +async fn handle_dob_decode( + Path(spore_id): Path, + axum::extract::State(server): axum::extract::State, +) -> impl IntoResponse { + tracing::info!("RESTful API: decoding spore_id {}", spore_id); + + match server.service_decode(spore_id).await { + Ok(result) => { + tracing::info!("RESTful API: decode successful"); + (StatusCode::OK, Html(result)) + } + Err(error) => { + tracing::error!("RESTful API: decode failed: {}", error); + ( + StatusCode::BAD_REQUEST, + Html(format!("Error: {}", error.message())), + ) + } + } +} + +/// Handle dob_batch_decode RESTful endpoint +async fn handle_dob_batch_decode( + Path(spore_ids): Path, + axum::extract::State(server): axum::extract::State, +) -> impl IntoResponse { + tracing::info!("RESTful API: batch decoding spore_ids: {}", spore_ids); + + // Parse comma-separated spore IDs + let spore_id_list: Vec = spore_ids.split(',').map(|s| s.trim().to_string()).collect(); + + match server.service_batch_decode(spore_id_list).await { + Ok(results) => { + tracing::info!("RESTful API: batch decode successful"); + let json_result = serde_json::to_string(&results).unwrap_or_else(|_| "[]".to_string()); + (StatusCode::OK, Html(json_result)) + } + Err(error) => { + tracing::error!("RESTful API: batch decode failed: {}", error); + ( + StatusCode::BAD_REQUEST, + Html(format!("Error: {}", error.message())), + ) + } + } +} + +/// Handle dob_raw_decode RESTful endpoint +async fn handle_dob_raw_decode( + Path((spore_data, cluster_data)): Path<(String, String)>, + axum::extract::State(server): axum::extract::State, +) -> impl IntoResponse { + tracing::info!( + "RESTful API: raw decoding spore_data: {}, cluster_data: {}", + spore_data, + cluster_data + ); + + match server.service_raw_decode(spore_data, cluster_data).await { + Ok(result) => { + tracing::info!("RESTful API: raw decode successful"); + (StatusCode::OK, Html(result)) + } + Err(error) => { + tracing::error!("RESTful API: raw decode failed: {}", error); + ( + StatusCode::BAD_REQUEST, + Html(format!("Error: {}", error.message())), + ) + } + } +} + +/// Handle dob_extract_image_from_fsuri RESTful endpoint +async fn handle_extract_image_from_fsuri( + Path(fsuri): Path, + axum::extract::State(server): axum::extract::State, +) -> impl IntoResponse { + tracing::info!("RESTful API: extracting image from fsuri: {}", fsuri); + + match server.service_extract_image_from_fsuri(fsuri, None).await { + Ok(result) => { + tracing::info!("RESTful API: image extraction successful"); + (StatusCode::OK, Html(result)) + } + Err(error) => { + tracing::error!("RESTful API: image extraction failed: {}", error); + ( + StatusCode::BAD_REQUEST, + Html(format!("Error: {}", error.message())), + ) + } + } +} + +/// Handle protocol_versions RESTful endpoint +async fn handle_protocol_versions( + axum::extract::State(server): axum::extract::State, +) -> impl IntoResponse { + tracing::info!("RESTful API: getting protocol versions"); + + let versions = server.service_protocol_versions().await; + let json_result = serde_json::to_string(&versions).unwrap_or_else(|_| "[]".to_string()); + + tracing::info!("RESTful API: protocol versions retrieved successfully"); + (StatusCode::OK, Html(json_result)) +} diff --git a/src/server/server.rs b/src/server/server.rs new file mode 100644 index 0000000..f6f41a1 --- /dev/null +++ b/src/server/server.rs @@ -0,0 +1,237 @@ +use std::path::PathBuf; + +use base64::{engine::general_purpose::STANDARD, Engine}; +use jsonrpsee::core::async_trait; +use jsonrpsee::{proc_macros::rpc, tracing, types::error::ErrorObjectOwned}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::decoder::{ + helpers::{decode_cluster_data, decode_spore_data}, + DOBDecoder, +}; +use crate::server::utils::{read_dob_from_cache, trim_0x, write_dob_to_cache}; +use crate::svg::DOBSvgExtractor; +use crate::types::Error; + +// decoding result contains rendered result from native decoder and DNA string for optional use +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ServerDecodeResult { + render_output: String, + dob_content: Value, +} + +#[rpc(server)] +trait DecoderRpc { + #[method(name = "dob_protocol_version")] + async fn protocol_versions(&self) -> Vec; + + #[method(name = "dob_decode")] + async fn decode(&self, hexed_spore_id: String) -> Result; + + #[method(name = "dob_batch_decode")] + async fn batch_decode( + &self, + hexed_spore_ids: Vec, + ) -> Result, ErrorObjectOwned>; + + #[method(name = "dob_raw_decode")] + async fn raw_decode( + &self, + spore_data: String, + cluster_data: String, + ) -> Result; + + #[method(name = "dob_decode_svg")] + async fn decode_svg(&self, hexed_spore_id: String) -> Result; + + #[method(name = "dob_extract_image_from_fsuri")] + async fn extract_image_from_fsuri( + &self, + fsuri: String, + encode_type: Option, + ) -> Result; +} + +#[async_trait] +impl DecoderRpcServer for DecoderStandaloneServer { + async fn protocol_versions(&self) -> Vec { + self.service_protocol_versions().await + } + + // decode DNA in particular spore DOB cell + async fn decode(&self, hexed_spore_id: String) -> Result { + self.service_decode(hexed_spore_id).await + } + + // decode DNA from a set + async fn batch_decode( + &self, + hexed_spore_ids: Vec, + ) -> Result, ErrorObjectOwned> { + self.service_batch_decode(hexed_spore_ids).await + } + + // decode directly from spore and cluster data + async fn raw_decode( + &self, + hexed_spore_data: String, + hexed_cluster_data: String, + ) -> Result { + self.service_raw_decode(hexed_spore_data, hexed_cluster_data) + .await + } + + async fn decode_svg(&self, hexed_spore_id: String) -> Result { + self.service_decode_svg(hexed_spore_id).await + } + + async fn extract_image_from_fsuri( + &self, + fsuri: String, + encode_type: Option, + ) -> Result { + self.service_extract_image_from_fsuri(fsuri, encode_type) + .await + } +} + +#[derive(Clone)] +pub struct DecoderStandaloneServer { + decoder: DOBDecoder, + cache_expiration: u64, + svg_extractor: DOBSvgExtractor, +} + +impl DecoderStandaloneServer { + pub fn new(decoder: DOBDecoder, cache_expiration: u64) -> Self { + Self { + svg_extractor: DOBSvgExtractor::new(&decoder.setting().image_fetcher_url), + decoder, + cache_expiration, + } + } + + /// Abstracted service method for decoding DOB to JSON + pub async fn service_decode(&self, hexed_spore_id: String) -> Result { + tracing::info!("decoding spore_id {hexed_spore_id}"); + let spore_id: [u8; 32] = hex::decode(trim_0x(&hexed_spore_id)) + .map_err(|_| Error::HexedSporeIdParseError)? + .try_into() + .map_err(|_| Error::SporeIdLengthInvalid)?; + let mut cache_path = self.decoder.setting().dobs_cache_directory.clone(); + cache_path.push(format!("{}.dob", hex::encode(spore_id))); + let (render_output, dob_content) = + if let Some(cache) = read_dob_from_cache(cache_path.clone(), self.cache_expiration)? { + cache + } else { + self.cache_decode(spore_id, cache_path).await? + }; + let result = serde_json::to_string(&ServerDecodeResult { + render_output, + dob_content, + }) + .unwrap(); + tracing::info!("spore_id {hexed_spore_id}, result: {result}"); + Ok(result) + } + + /// Abstracted service method for batch decoding + pub async fn service_batch_decode( + &self, + hexed_spore_ids: Vec, + ) -> Result, ErrorObjectOwned> { + let mut await_results = Vec::new(); + for hexed_spore_id in hexed_spore_ids { + await_results.push(self.service_decode(hexed_spore_id)); + } + let results = futures::future::join_all(await_results) + .await + .into_iter() + .map(|result| match result { + Ok(result) => result, + Err(error) => format!("server error: {error}"), + }) + .collect(); + Ok(results) + } + + /// Abstracted service method for raw decoding + pub async fn service_raw_decode( + &self, + hexed_spore_data: String, + hexed_cluster_data: String, + ) -> Result { + let spore_data = + hex::decode(trim_0x(&hexed_spore_data)).map_err(|_| Error::SporeDataUncompatible)?; + let cluster_data = hex::decode(trim_0x(&hexed_cluster_data)) + .map_err(|_| Error::ClusterDataUncompatible)?; + let dob = decode_spore_data(&spore_data)?; + let dob_metadata = decode_cluster_data(&cluster_data)?; + let render_output = self.decoder.decode_dna(&dob.dna, dob_metadata).await?; + let result = serde_json::to_string(&ServerDecodeResult { + render_output, + dob_content: dob.content, + }) + .unwrap(); + tracing::info!("raw, result: {result}"); + Ok(result) + } + + /// Abstracted service method for SVG decoding + pub async fn service_decode_svg( + &self, + hexed_spore_id: String, + ) -> Result { + let ServerDecodeResult { + render_output, + dob_content: _, + } = serde_json::from_str(&self.service_decode(hexed_spore_id).await?) + .map_err(|e| ErrorObjectOwned::owned(-1, e.to_string(), None::<()>))?; + let svg = self.svg_extractor.extract_svg(render_output).await?; + Ok(svg) + } + + /// Abstracted service method for image extraction + pub async fn service_extract_image_from_fsuri( + &self, + fsuri: String, + encode_type: Option, + ) -> Result { + let raw_images = self + .svg_extractor + .get_fetcher() + .fetch_images(&[fsuri]) + .await?; + let image = raw_images.first().ok_or(Error::NoImageFound)?; + + match encode_type.as_deref() { + Some("hex") | None => Ok(hex::encode(image)), + Some("base64") => Ok(STANDARD.encode(image)), + unknown => Err(ErrorObjectOwned::owned::( + -1, + format!( + "Unknown encode type: {}. Supported types: 'base64', 'hex' (default)", + unknown.unwrap_or("unknown") + ), + None, + )), + } + } + + /// Abstracted service method for protocol versions + pub async fn service_protocol_versions(&self) -> Vec { + self.decoder.protocol_versions() + } + + async fn cache_decode( + &self, + spore_id: [u8; 32], + cache_path: PathBuf, + ) -> Result<(String, Value), Error> { + let (content, dna, metadata) = self.decoder.fetch_decode_ingredients(spore_id).await?; + let render_output = self.decoder.decode_dna(&dna, metadata).await?; + write_dob_to_cache(&render_output, &content, cache_path, self.cache_expiration)?; + Ok((render_output, content)) + } +} diff --git a/src/server/utils.rs b/src/server/utils.rs new file mode 100644 index 0000000..9e692f1 --- /dev/null +++ b/src/server/utils.rs @@ -0,0 +1,69 @@ +use serde_json::Value; +use std::fs; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use crate::types::Error; + +pub fn trim_0x(hexed: &str) -> &str { + hexed.trim_start_matches("0x") +} + +pub fn read_dob_from_cache( + cache_path: PathBuf, + mut expiration: u64, +) -> Result, Error> { + if !cache_path.exists() { + return Ok(None); + } + let file_content = fs::read_to_string(&cache_path) + .map_err(|_| Error::DOBRenderCacheNotFound(cache_path.clone()))?; + let mut lines = file_content.split('\n'); + let (Some(result), Some(content), timestamp) = (lines.next(), lines.next(), lines.next()) + else { + return Err(Error::DOBRenderCacheModified(cache_path)); + }; + if let Some(value) = timestamp { + if !value.is_empty() { + expiration = value + .parse::() + .map_err(|_| Error::DOBRenderCacheModified(cache_path.clone()))?; + } + } + match serde_json::from_str(content) { + Ok(content) => { + if expiration > 0 && now()? > Duration::from_secs(expiration) { + Ok(None) + } else { + Ok(Some((result.to_string(), content))) + } + } + Err(_) => Err(Error::DOBRenderCacheModified(cache_path)), + } +} + +pub fn write_dob_to_cache( + render_result: &str, + dob_content: &Value, + cache_path: PathBuf, + cache_expiration: u64, +) -> Result<(), Error> { + let expiration_timestamp = if cache_expiration > 0 { + now()? + .checked_add(Duration::from_secs(cache_expiration)) + .ok_or(Error::SystemTimeError)? + .as_secs() + } else { + 0 // zero means always read from cache + }; + let json_dob_content = serde_json::to_string(dob_content).unwrap(); + let file_content = format!("{render_result}\n{json_dob_content}\n{expiration_timestamp}"); + fs::write(&cache_path, file_content).map_err(|_| Error::DOBRenderCacheNotFound(cache_path))?; + Ok(()) +} + +pub fn now() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Error::SystemTimeError) +} diff --git a/src/types.rs b/src/types.rs index 9cbf073..a1e4564 100644 --- a/src/types.rs +++ b/src/types.rs @@ -292,6 +292,7 @@ pub struct Settings { )] pub image_fetcher_url: HashMap, pub rpc_server_address: String, + pub restful_server_address: Option, pub decoders_cache_directory: PathBuf, pub dobs_cache_directory: PathBuf, pub dobs_cache_expiration_sec: u64, From d74fcc18345f050c82022374018cb12336461252 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Thu, 11 Sep 2025 20:16:00 +0800 Subject: [PATCH 20/26] bug: fix w4 to w3 --- src/svg/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/svg/mod.rs b/src/svg/mod.rs index 00c851a..61ac8d7 100644 --- a/src/svg/mod.rs +++ b/src/svg/mod.rs @@ -156,7 +156,7 @@ impl DOBSvgExtractor { let image_content_base64 = STANDARD.encode(&image_content); let bgcolor = bgcolor.unwrap_or("#000"); let svg_content = format!( - r#""# + r#""# ); Ok(Some(svg_content)) } else { From b80581def9d1f204737531054621289482e8862a Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Thu, 11 Sep 2025 21:57:46 +0800 Subject: [PATCH 21/26] bug: arbitrary image type and hide background style if no bgcolor --- src/client.rs | 71 +++++++++++++++++++++++++++++++++++++++++++------- src/svg/mod.rs | 6 +++-- src/types.rs | 4 +-- 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/src/client.rs b/src/client.rs index a38e6e6..d8f5741 100644 --- a/src/client.rs +++ b/src/client.rs @@ -10,7 +10,7 @@ use ckb_jsonrpc_types::{ use ckb_sdk::rpc::ckb_indexer::{Cell, Order, Pagination, SearchKey, Tx}; use ckb_types::H256; use jsonrpc_core::futures::FutureExt; -use lazy_regex::regex_replace_all; +use lazy_regex::{regex, regex_replace_all}; use reqwest::{Client, ClientBuilder, Url}; use serde_json::Value; use std::time::Duration; @@ -360,21 +360,35 @@ async fn parse_image_from_btcfs(url: Url, index: usize) -> Result, Error // parse inscription body let mut images = vec![]; let mut witness_view = witness.as_str(); - const HEADER: &str = "OP_IF OP_PUSHBYTES_3 444f42 OP_PUSHBYTES_1 01 OP_PUSHBYTES_9 696d6167652f706e67 OP_0 OP_PUSHDATA2 "; + + // flexible header pattern, e.g. OP_IF OP_PUSHBYTES_3 444f42 OP_PUSHBYTES_1 01 OP_PUSHBYTES_9 696d6167652f706e67 OP_0 OP_PUSHDATA2 + // note: arbitrary part is the specification of image type + let header_pattern = regex!( + r#"OP_IF\s+OP_PUSHBYTES_3\s+444f42\s+OP_PUSHBYTES_1\s+01\s+OP_PUSHBYTES_(\d+)\s+([0-9a-fA-F]+)\s+OP_0\s+OP_PUSHDATA2\s+"# + ); + while let (Some(start), Some(end)) = (witness_view.find("OP_IF"), witness_view.find("OP_ENDIF")) { if start >= end { - return Err(Error::InvalidInscriptionFormat); + return Err(Error::InvalidInscriptionFormat( + "bad start and end position".to_string(), + )); } let inscription = &witness_view[start..end + "OP_ENDIF".len()]; - if !inscription.contains(HEADER) { - return Err(Error::InvalidInscriptionFormat); + + if let Some(captures) = header_pattern.captures(inscription) { + let matched_header = &captures[0]; + let base_removed = inscription.replace(matched_header, ""); + let hexed = regex_replace_all!(r#"\s?OP\_\w+\s?"#, &base_removed, ""); + let image = hex::decode(hexed.as_bytes()) + .map_err(|_| Error::InvalidInscriptionContentHexFormat)?; + images.push(image); + } else { + return Err(Error::InvalidInscriptionFormat( + "HEADER pattern not found".to_string(), + )); } - let base_removed = inscription.replace(HEADER, ""); - let hexed = regex_replace_all!(r#"\s?OP\_\w+\s?"#, &base_removed, ""); - let image = - hex::decode(hexed.as_bytes()).map_err(|_| Error::InvalidInscriptionContentHexFormat)?; - images.push(image); + witness_view = &witness_view[end + "OP_ENDIF".len()..]; } if images.is_empty() { @@ -387,3 +401,40 @@ async fn parse_image_from_btcfs(url: Url, index: usize) -> Result, Error .ok_or(Error::ExceededInscriptionIndex)?; Ok(image) } + +#[cfg(test)] +mod tests { + use lazy_regex::regex; + + #[test] + fn test_header_regex_pattern() { + // Test the regex pattern with the example from the user + let header_pattern = regex!( + r#"OP_IF\s+OP_PUSHBYTES_3\s+444f42\s+OP_PUSHBYTES_1\s+01\s+OP_PUSHBYTES_(\d+)\s+([0-9a-fA-F]+)\s+OP_0\s+OP_PUSHDATA2\s+"# + ); + + // Test with the original fixed header + let original_header = "OP_IF OP_PUSHBYTES_3 444f42 OP_PUSHBYTES_1 01 OP_PUSHBYTES_9 696d6167652f706e67 OP_0 OP_PUSHDATA2 "; + assert!(header_pattern.is_match(original_header)); + + // Test with different hex data (the user's example) + let test_header = "OP_IF OP_PUSHBYTES_3 444f42 OP_PUSHBYTES_1 01 OP_PUSHBYTES_9 696d6167652f706e67 OP_0 OP_PUSHDATA2 "; + assert!(header_pattern.is_match(test_header)); + + // Test with different byte count + let different_bytes = "OP_IF OP_PUSHBYTES_3 444f42 OP_PUSHBYTES_1 01 OP_PUSHBYTES_12 1234567890abcdef123456 OP_0 OP_PUSHDATA2 "; + assert!(header_pattern.is_match(different_bytes)); + + // Test that it captures the byte count and hex data + if let Some(captures) = header_pattern.captures(test_header) { + assert_eq!(&captures[1], "9"); // byte count + assert_eq!(&captures[2], "696d6167652f706e67"); // hex data + } else { + panic!("Regex should have captured the groups"); + } + + // Test that invalid headers don't match + let invalid_header = "OP_IF OP_PUSHBYTES_3 444f42 OP_PUSHBYTES_1 01 OP_PUSHBYTES_9 invalid_hex OP_0 OP_PUSHDATA2 "; + assert!(!header_pattern.is_match(invalid_header)); + } +} diff --git a/src/svg/mod.rs b/src/svg/mod.rs index 61ac8d7..e3d4ad1 100644 --- a/src/svg/mod.rs +++ b/src/svg/mod.rs @@ -154,9 +154,11 @@ impl DOBSvgExtractor { return Ok(None); }; let image_content_base64 = STANDARD.encode(&image_content); - let bgcolor = bgcolor.unwrap_or("#000"); + let bgcolor_style = bgcolor + .map(|v| format!("")) + .unwrap_or_default(); let svg_content = format!( - r#""# + r#"{bgcolor_style}"# ); Ok(Some(svg_content)) } else { diff --git a/src/types.rs b/src/types.rs index a1e4564..bf3c82d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -80,8 +80,8 @@ pub enum Error { FetchFromBtcNodeError(String), #[error("BTC transaction format has broken: {0}")] InvalidBtcTransactionFormat(String), - #[error("Inscription format broken")] - InvalidInscriptionFormat, + #[error("Inscription format broken: {0}")] + InvalidInscriptionFormat(String), #[error("Inscription content must be hex format")] InvalidInscriptionContentHexFormat, #[error("Inscription content must be filled")] From e14a327bc7828d2dd3b371ca42583d50c085f019 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Fri, 12 Sep 2025 12:53:06 +0800 Subject: [PATCH 22/26] bug: solve dob/1 fallback issue --- src/svg/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/svg/mod.rs b/src/svg/mod.rs index e3d4ad1..089c2e7 100644 --- a/src/svg/mod.rs +++ b/src/svg/mod.rs @@ -224,7 +224,7 @@ impl DOBSvgExtractor { // Let upstream fallback to next process if no URLs are found if all_urls.is_empty() { - return Ok(None); + return Ok(Some(svg_content)); } // Fetch all images From e37f5fa4672d5978b22ad2b13e2c7e473980d8d7 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Tue, 16 Sep 2025 08:50:09 +0800 Subject: [PATCH 23/26] feat: purge svg handler, keep fsuri extractor alive --- src/client.rs | 124 +----- src/lib.rs | 1 - src/main.rs | 1 - src/server/{server.rs => core.rs} | 33 +- src/server/jsonrpc.rs | 13 +- src/server/mod.rs | 4 +- src/server/restful.rs | 85 ++--- src/svg/mod.rs | 250 ------------- src/svg/puretext/constants.rs | 54 --- src/svg/puretext/font/mod.rs | 11 - src/svg/puretext/font/path.rs | 235 ------------ src/svg/puretext/font/turretroad-400.ttf | Bin 48272 -> 0 bytes src/svg/puretext/font/turretroad-700.ttf | Bin 48248 -> 0 bytes src/svg/puretext/mod.rs | 37 -- src/svg/puretext/parsers/background_parser.rs | 39 -- src/svg/puretext/parsers/mod.rs | 9 - src/svg/puretext/parsers/style_parser.rs | 117 ------ src/svg/puretext/parsers/text_parser.rs | 177 --------- src/svg/puretext/parsers/traits_parser.rs | 153 -------- src/svg/puretext/render.rs | 353 ------------------ src/tests/dob0/legacy_decoder.rs | 179 +-------- src/tests/dob1/decoder.rs | 13 - src/tests/mod.rs | 1 + 23 files changed, 35 insertions(+), 1854 deletions(-) rename src/server/{server.rs => core.rs} (86%) delete mode 100644 src/svg/mod.rs delete mode 100644 src/svg/puretext/constants.rs delete mode 100644 src/svg/puretext/font/mod.rs delete mode 100644 src/svg/puretext/font/path.rs delete mode 100644 src/svg/puretext/font/turretroad-400.ttf delete mode 100644 src/svg/puretext/font/turretroad-700.ttf delete mode 100644 src/svg/puretext/mod.rs delete mode 100644 src/svg/puretext/parsers/background_parser.rs delete mode 100644 src/svg/puretext/parsers/mod.rs delete mode 100644 src/svg/puretext/parsers/style_parser.rs delete mode 100644 src/svg/puretext/parsers/text_parser.rs delete mode 100644 src/svg/puretext/parsers/traits_parser.rs delete mode 100644 src/svg/puretext/render.rs diff --git a/src/client.rs b/src/client.rs index d8f5741..9159b2f 100644 --- a/src/client.rs +++ b/src/client.rs @@ -11,9 +11,8 @@ use ckb_sdk::rpc::ckb_indexer::{Cell, Order, Pagination, SearchKey, Tx}; use ckb_types::H256; use jsonrpc_core::futures::FutureExt; use lazy_regex::{regex, regex_replace_all}; -use reqwest::{Client, ClientBuilder, Url}; +use reqwest::{Client, Url}; use serde_json::Value; -use std::time::Duration; use crate::types::Error; @@ -158,21 +157,12 @@ impl RpcClient { #[derive(Clone)] pub struct ImageFetchClient { base_url: HashMap, - client: Client, } impl ImageFetchClient { pub fn new(base_url: &HashMap) -> Self { - let client = ClientBuilder::new() - .timeout(Duration::from_secs(30)) // 30 seconds timeout - .connect_timeout(Duration::from_secs(10)) // 10 seconds connection timeout - .danger_accept_invalid_certs(true) // Bypass SSL certificate verification - .build() - .expect("Failed to create HTTP client"); - Self { base_url: base_url.clone(), - client, } } @@ -210,75 +200,6 @@ impl ImageFetchClient { .boxed(), ); } - URI::Http(url) => { - let client = self.client.clone(); - requests.push( - async move { - let response = client.get(url.clone()).send().await.map_err(|e| { - Error::FetchFromHttpError(format!("HTTP request failed: {}", e)) - })?; - - if !response.status().is_success() { - return Err(Error::FetchFromHttpError(format!( - "HTTP request failed with status: {}", - response.status() - ))); - } - - let image = response - .bytes() - .await - .map_err(|e| { - Error::FetchFromHttpError(format!( - "Failed to read response body: {}", - e - )) - })? - .to_vec(); - Ok(image) - } - .boxed(), - ); - } - URI::Https(url) => { - let client = self.client.clone(); - requests.push( - async move { - let response = client.get(url.clone()).send().await.map_err(|e| { - let error_msg = if e.is_connect() { - format!("HTTPS connection failed: {}", e) - } else if e.is_timeout() { - format!("HTTPS request timeout: {}", e) - } else if e.is_request() { - format!("HTTPS request error: {}", e) - } else { - format!("HTTPS error: {}", e) - }; - Error::FetchFromHttpError(error_msg) - })?; - - if !response.status().is_success() { - return Err(Error::FetchFromHttpError(format!( - "HTTPS request failed with status: {}", - response.status() - ))); - } - - let image = response - .bytes() - .await - .map_err(|e| { - Error::FetchFromHttpError(format!( - "Failed to read HTTPS response body: {}", - e - )) - })? - .to_vec(); - Ok(image) - } - .boxed(), - ); - } } } let mut images = vec![]; @@ -294,8 +215,6 @@ impl ImageFetchClient { enum URI { BTCFS(String, usize), IPFS(String), - Http(String), - Https(String), } impl TryFrom<&String> for URI { @@ -315,10 +234,6 @@ impl TryFrom<&String> for URI { } else if let Some(body) = uri.strip_prefix("ipfs://") { let hash = body.to_string(); Ok(URI::IPFS(hash)) - } else if uri.starts_with("https") { - Ok(URI::Https(uri.clone())) - } else if uri.starts_with("http") { - Ok(URI::Http(uri.clone())) } else { Err(Error::InvalidOnchainFsuriFormat) } @@ -401,40 +316,3 @@ async fn parse_image_from_btcfs(url: Url, index: usize) -> Result, Error .ok_or(Error::ExceededInscriptionIndex)?; Ok(image) } - -#[cfg(test)] -mod tests { - use lazy_regex::regex; - - #[test] - fn test_header_regex_pattern() { - // Test the regex pattern with the example from the user - let header_pattern = regex!( - r#"OP_IF\s+OP_PUSHBYTES_3\s+444f42\s+OP_PUSHBYTES_1\s+01\s+OP_PUSHBYTES_(\d+)\s+([0-9a-fA-F]+)\s+OP_0\s+OP_PUSHDATA2\s+"# - ); - - // Test with the original fixed header - let original_header = "OP_IF OP_PUSHBYTES_3 444f42 OP_PUSHBYTES_1 01 OP_PUSHBYTES_9 696d6167652f706e67 OP_0 OP_PUSHDATA2 "; - assert!(header_pattern.is_match(original_header)); - - // Test with different hex data (the user's example) - let test_header = "OP_IF OP_PUSHBYTES_3 444f42 OP_PUSHBYTES_1 01 OP_PUSHBYTES_9 696d6167652f706e67 OP_0 OP_PUSHDATA2 "; - assert!(header_pattern.is_match(test_header)); - - // Test with different byte count - let different_bytes = "OP_IF OP_PUSHBYTES_3 444f42 OP_PUSHBYTES_1 01 OP_PUSHBYTES_12 1234567890abcdef123456 OP_0 OP_PUSHDATA2 "; - assert!(header_pattern.is_match(different_bytes)); - - // Test that it captures the byte count and hex data - if let Some(captures) = header_pattern.captures(test_header) { - assert_eq!(&captures[1], "9"); // byte count - assert_eq!(&captures[2], "696d6167652f706e67"); // hex data - } else { - panic!("Regex should have captured the groups"); - } - - // Test that invalid headers don't match - let invalid_header = "OP_IF OP_PUSHBYTES_3 444f42 OP_PUSHBYTES_1 01 OP_PUSHBYTES_9 invalid_hex OP_0 OP_PUSHDATA2 "; - assert!(!header_pattern.is_match(invalid_header)); - } -} diff --git a/src/lib.rs b/src/lib.rs index 250533d..93f172c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,5 +4,4 @@ mod vm; pub mod client; pub mod decoder; -pub mod svg; pub mod types; diff --git a/src/main.rs b/src/main.rs index a087593..6a8415c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,6 @@ use tracing_subscriber::EnvFilter; mod client; mod decoder; mod server; -mod svg; mod types; mod vm; diff --git a/src/server/server.rs b/src/server/core.rs similarity index 86% rename from src/server/server.rs rename to src/server/core.rs index f6f41a1..861f29c 100644 --- a/src/server/server.rs +++ b/src/server/core.rs @@ -6,12 +6,12 @@ use jsonrpsee::{proc_macros::rpc, tracing, types::error::ErrorObjectOwned}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::client::ImageFetchClient; use crate::decoder::{ helpers::{decode_cluster_data, decode_spore_data}, DOBDecoder, }; use crate::server::utils::{read_dob_from_cache, trim_0x, write_dob_to_cache}; -use crate::svg::DOBSvgExtractor; use crate::types::Error; // decoding result contains rendered result from native decoder and DNA string for optional use @@ -42,9 +42,6 @@ trait DecoderRpc { cluster_data: String, ) -> Result; - #[method(name = "dob_decode_svg")] - async fn decode_svg(&self, hexed_spore_id: String) -> Result; - #[method(name = "dob_extract_image_from_fsuri")] async fn extract_image_from_fsuri( &self, @@ -82,10 +79,6 @@ impl DecoderRpcServer for DecoderStandaloneServer { .await } - async fn decode_svg(&self, hexed_spore_id: String) -> Result { - self.service_decode_svg(hexed_spore_id).await - } - async fn extract_image_from_fsuri( &self, fsuri: String, @@ -100,13 +93,13 @@ impl DecoderRpcServer for DecoderStandaloneServer { pub struct DecoderStandaloneServer { decoder: DOBDecoder, cache_expiration: u64, - svg_extractor: DOBSvgExtractor, + image_fetcher: ImageFetchClient, } impl DecoderStandaloneServer { pub fn new(decoder: DOBDecoder, cache_expiration: u64) -> Self { Self { - svg_extractor: DOBSvgExtractor::new(&decoder.setting().image_fetcher_url), + image_fetcher: ImageFetchClient::new(&decoder.setting().image_fetcher_url), decoder, cache_expiration, } @@ -178,31 +171,13 @@ impl DecoderStandaloneServer { Ok(result) } - /// Abstracted service method for SVG decoding - pub async fn service_decode_svg( - &self, - hexed_spore_id: String, - ) -> Result { - let ServerDecodeResult { - render_output, - dob_content: _, - } = serde_json::from_str(&self.service_decode(hexed_spore_id).await?) - .map_err(|e| ErrorObjectOwned::owned(-1, e.to_string(), None::<()>))?; - let svg = self.svg_extractor.extract_svg(render_output).await?; - Ok(svg) - } - /// Abstracted service method for image extraction pub async fn service_extract_image_from_fsuri( &self, fsuri: String, encode_type: Option, ) -> Result { - let raw_images = self - .svg_extractor - .get_fetcher() - .fetch_images(&[fsuri]) - .await?; + let raw_images = self.image_fetcher.fetch_images(&[fsuri]).await?; let image = raw_images.first().ok_or(Error::NoImageFound)?; match encode_type.as_deref() { diff --git a/src/server/jsonrpc.rs b/src/server/jsonrpc.rs index 542c4a6..aca101f 100644 --- a/src/server/jsonrpc.rs +++ b/src/server/jsonrpc.rs @@ -33,11 +33,8 @@ trait DecoderRpc { cluster_data: String, ) -> Result; - #[method(name = "dob_decode_svg")] - async fn decode_svg(&self, hexed_spore_id: String) -> Result; - - #[method(name = "dob_extract_image_from_fsuri")] - async fn extract_image_from_fsuri( + #[method(name = "dob_extract_image")] + async fn extract_image( &self, fsuri: String, encode_type: Option, @@ -73,11 +70,7 @@ impl DecoderRpcServer for DecoderStandaloneServer { .await } - async fn decode_svg(&self, hexed_spore_id: String) -> Result { - self.service_decode_svg(hexed_spore_id).await - } - - async fn extract_image_from_fsuri( + async fn extract_image( &self, fsuri: String, encode_type: Option, diff --git a/src/server/mod.rs b/src/server/mod.rs index 9d3064b..8db9c4e 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,7 +1,7 @@ +mod core; mod jsonrpc; mod restful; -mod server; mod utils; +pub use core::DecoderStandaloneServer; pub use jsonrpc::DecoderRpcServer; -pub use server::DecoderStandaloneServer; diff --git a/src/server/restful.rs b/src/server/restful.rs index 177013f..339dbe7 100644 --- a/src/server/restful.rs +++ b/src/server/restful.rs @@ -1,60 +1,39 @@ use axum::{ - extract::Path, + extract::{Path, Query, State}, http::StatusCode, response::{Html, IntoResponse}, routing::get, Router, }; use jsonrpsee::tracing; +use serde::Deserialize; use crate::server::DecoderStandaloneServer; +#[derive(Deserialize)] +struct ExtractImageQuery { + encode: Option, +} + // RESTful API implementation impl DecoderStandaloneServer { /// Create RESTful API routes pub fn create_restful_routes() -> Router { Router::new() - .route("/dob_decode_svg/:spore_id", get(handle_dob_decode_svg)) .route("/dob_decode/:spore_id", get(handle_dob_decode)) .route("/dob_batch_decode/:spore_ids", get(handle_dob_batch_decode)) .route( - "/dob_raw_decode/:spore_data/:cluster_data", - get(handle_dob_raw_decode), - ) - .route( - "/dob_extract_image_from_fsuri/:fsuri", + "/dob_extract_image/:fsproto/:uri", get(handle_extract_image_from_fsuri), ) .route("/protocol_versions", get(handle_protocol_versions)) } } -/// Handle dob_decode_svg RESTful endpoint -async fn handle_dob_decode_svg( - Path(spore_id): Path, - axum::extract::State(server): axum::extract::State, -) -> impl IntoResponse { - tracing::info!("RESTful API: decoding SVG for spore_id {}", spore_id); - - match server.service_decode_svg(spore_id).await { - Ok(svg_content) => { - tracing::info!("RESTful API: SVG decoded successfully"); - (StatusCode::OK, Html(svg_content)) - } - Err(error) => { - tracing::error!("RESTful API: SVG decode failed: {}", error); - ( - StatusCode::BAD_REQUEST, - Html(format!("Error: {}", error.message())), - ) - } - } -} - /// Handle dob_decode RESTful endpoint async fn handle_dob_decode( Path(spore_id): Path, - axum::extract::State(server): axum::extract::State, + State(server): State, ) -> impl IntoResponse { tracing::info!("RESTful API: decoding spore_id {}", spore_id); @@ -76,7 +55,7 @@ async fn handle_dob_decode( /// Handle dob_batch_decode RESTful endpoint async fn handle_dob_batch_decode( Path(spore_ids): Path, - axum::extract::State(server): axum::extract::State, + State(server): State, ) -> impl IntoResponse { tracing::info!("RESTful API: batch decoding spore_ids: {}", spore_ids); @@ -99,40 +78,22 @@ async fn handle_dob_batch_decode( } } -/// Handle dob_raw_decode RESTful endpoint -async fn handle_dob_raw_decode( - Path((spore_data, cluster_data)): Path<(String, String)>, - axum::extract::State(server): axum::extract::State, -) -> impl IntoResponse { - tracing::info!( - "RESTful API: raw decoding spore_data: {}, cluster_data: {}", - spore_data, - cluster_data - ); - - match server.service_raw_decode(spore_data, cluster_data).await { - Ok(result) => { - tracing::info!("RESTful API: raw decode successful"); - (StatusCode::OK, Html(result)) - } - Err(error) => { - tracing::error!("RESTful API: raw decode failed: {}", error); - ( - StatusCode::BAD_REQUEST, - Html(format!("Error: {}", error.message())), - ) - } - } -} - /// Handle dob_extract_image_from_fsuri RESTful endpoint async fn handle_extract_image_from_fsuri( - Path(fsuri): Path, - axum::extract::State(server): axum::extract::State, + Path((fsproto, uri)): Path<(String, String)>, + Query(query): Query, + State(server): State, ) -> impl IntoResponse { - tracing::info!("RESTful API: extracting image from fsuri: {}", fsuri); + tracing::info!( + "RESTful API: extracting image from fsuri: {fsproto}://{uri} with encode: {:?}", + query.encode + ); - match server.service_extract_image_from_fsuri(fsuri, None).await { + let fsuri = format!("{}://{}", fsproto, uri); + match server + .service_extract_image_from_fsuri(fsuri, query.encode) + .await + { Ok(result) => { tracing::info!("RESTful API: image extraction successful"); (StatusCode::OK, Html(result)) @@ -149,7 +110,7 @@ async fn handle_extract_image_from_fsuri( /// Handle protocol_versions RESTful endpoint async fn handle_protocol_versions( - axum::extract::State(server): axum::extract::State, + State(server): State, ) -> impl IntoResponse { tracing::info!("RESTful API: getting protocol versions"); diff --git a/src/svg/mod.rs b/src/svg/mod.rs deleted file mode 100644 index 089c2e7..0000000 --- a/src/svg/mod.rs +++ /dev/null @@ -1,250 +0,0 @@ -use std::collections::HashMap; - -use base64::{engine::general_purpose::STANDARD, Engine}; -use lazy_regex::regex; -use reqwest::Url; -use serde_json::Value; - -use crate::{ - client::ImageFetchClient, - svg::puretext::{ - parsers::{dob_output_parser, render_text_params_parser}, - render::render_text_parser_result_to_svg, - }, - types::{Error, StandardDOBOutput}, -}; - -pub mod puretext; - -const DOB0_TRAIT_NAME: &str = "prev.bg"; -const DOB0_BGCOLOR_NAME: &str = "prev.bgcolor"; -const DOB1_TRAIT_NAME: &str = "IMAGE"; - -pub const DEFAULT_SIZE: u32 = 500; - -/// Detects the MIME type of an image from its hex-encoded content by examining file signatures. -/// Returns Some(mime_type) if recognized, or None if not recognized. -pub fn detect_image_mime_type(hex_content: String) -> Option<&'static str> { - // Skip if string is too short to contain a signature and content - if hex_content.len() < 64 { - return None; - } - - // Extract just the file header (first 32 bytes should be enough for most formats) - // and convert to lowercase for consistent comparison - let header = &hex_content[..64].to_ascii_lowercase(); - - // JPEG: starts with ffd8ff - if header.starts_with("ffd8ff") { - return Some("image/jpeg"); - } - - // PNG: starts with 89504e47 (‰PNG) - if header.starts_with("89504e47") { - return Some("image/png"); - } - - // GIF: starts with 474946 (GIF) - if header.starts_with("474946") { - return Some("image/gif"); - } - - // WebP: RIFF....WEBP - if header.starts_with("52494646") && header.get(16..24) == Some("57454250") { - return Some("image/webp"); - } - - // BMP: starts with 424d (BM) - if header.starts_with("424d") { - return Some("image/bmp"); - } - - // SVG: starts with ) -> Self { - Self { - fetcher: ImageFetchClient::new(base_url), - } - } - - pub fn get_fetcher(&self) -> &ImageFetchClient { - &self.fetcher - } - - pub async fn extract_svg(&self, dob_render_output: String) -> Result { - let parsed_dob: Vec = - serde_json::from_str(&dob_render_output).map_err(|_| Error::DOBRenderOutputInvalid)?; - - if let Some(dob0_svg) = self.extract_png_svg_from_dob0(&parsed_dob).await? { - return Ok(dob0_svg); - } - - if let Some(dob1_svg) = self.extract_png_svg_from_dob1(&parsed_dob).await? { - return Ok(dob1_svg); - } - - self.extract_text_svg_from_dob0(&parsed_dob) - } - - async fn extract_png_svg_from_dob0( - &self, - parsed_dob: &[StandardDOBOutput], - ) -> Result, Error> { - let fsurl = parsed_dob.iter().find_map(|dob| { - if dob.name == DOB0_TRAIT_NAME { - if let Some(dob_trait) = dob.traits.iter().find(|value| value.type_ == "String") { - if let Value::String(fsurl) = &dob_trait.value { - if fsurl.starts_with("btcfs://") - || fsurl.starts_with("ipfs://") - || fsurl.starts_with("http") - { - return Some(fsurl); - } - } - } - } - None - }); - let bgcolor = parsed_dob.iter().find_map(|dob| { - if dob.name == DOB0_BGCOLOR_NAME { - if let Some(dob_trait) = dob.traits.iter().find(|value| value.type_ == "String") { - if let Value::String(bgcolor) = &dob_trait.value { - return Some(bgcolor.as_str()); - } - } - } - None - }); - if let Some(dob0_fsurl) = fsurl { - let image_content = self - .fetcher - .fetch_images(&[dob0_fsurl.clone()]) - .await? - .into_iter() - .next() - .ok_or(Error::FetchFromIpfsError("No image found".to_string()))?; - let Some(image_mime_type) = detect_image_mime_type(hex::encode(&image_content)) else { - return Ok(None); - }; - let image_content_base64 = STANDARD.encode(&image_content); - let bgcolor_style = bgcolor - .map(|v| format!("")) - .unwrap_or_default(); - let svg_content = format!( - r#"{bgcolor_style}"# - ); - Ok(Some(svg_content)) - } else { - Ok(None) - } - } - - async fn extract_png_svg_from_dob1( - &self, - parsed_dob: &[StandardDOBOutput], - ) -> Result, Error> { - let svg = parsed_dob.iter().find_map(|dob| { - if dob.name == DOB1_TRAIT_NAME { - if let Some(dob_trait) = dob.traits.iter().find(|value| value.type_ == "SVG") { - if let Value::String(svg) = &dob_trait.value { - return Some(svg); - } - } - } - None - }); - if let Some(svg) = svg { - Ok(self.replace_svg_fsurls(svg.clone()).await?) - } else { - Ok(None) - } - } - - fn extract_text_svg_from_dob0( - &self, - parsed_dob: &[StandardDOBOutput], - ) -> Result { - let dob_output_result = dob_output_parser(parsed_dob); - let text_render_result = render_text_params_parser( - &dob_output_result.traits, - &dob_output_result.index_var_register, - None, - ); - let svg = render_text_parser_result_to_svg(&text_render_result); - Ok(svg) - } - - async fn replace_svg_fsurls(&self, mut svg_content: String) -> Result, Error> { - // Create regex patterns to match btcfs:// and ipfs:// URLs in href attributes - let btcfs_pattern = regex!(r#"href=(?:'|")btcfs://([^'"]+)(?:'|")"#); - let ipfs_pattern = regex!(r#"href=(?:'|")ipfs://([^'"]+)(?:'|")"#); - - // Find all btcfs URLs - let btcfs_urls: Vec = btcfs_pattern - .captures_iter(&svg_content) - .map(|cap| format!("btcfs://{}", &cap[1])) - .collect(); - - // Find all ipfs URLs - let ipfs_urls: Vec = ipfs_pattern - .captures_iter(&svg_content) - .map(|cap| format!("ipfs://{}", &cap[1])) - .collect(); - - // Combine all URLs to fetch - let mut all_urls = btcfs_urls; - all_urls.extend(ipfs_urls); - - // Let upstream fallback to next process if no URLs are found - if all_urls.is_empty() { - return Ok(Some(svg_content)); - } - - // Fetch all images - let image_contents = self.fetcher.fetch_images(&all_urls).await?; - - // Replace URLs with base64 content - for (i, url) in all_urls.iter().enumerate() { - let image_content = &image_contents[i]; - let Some(mime_type) = detect_image_mime_type(hex::encode(image_content)) else { - continue; - }; - let base64_content = STANDARD.encode(image_content); - let data_url = format!("data:{};base64,{}", mime_type, base64_content,); - - // Replace the URL in the SVG - let old_href = format!("href='{}'", url); - let new_href = format!("href='{}'", data_url); - svg_content = svg_content.replace(&old_href, &new_href); - } - - Ok(Some(svg_content)) - } -} diff --git a/src/svg/puretext/constants.rs b/src/svg/puretext/constants.rs deleted file mode 100644 index c2f71e9..0000000 --- a/src/svg/puretext/constants.rs +++ /dev/null @@ -1,54 +0,0 @@ -use lazy_regex::{lazy_regex, regex, Lazy}; -use serde_json::Value; - -pub static ARRAY_REG: Lazy = lazy_regex!(r"\%(.*?)\):(\[.*?\])"); -pub static ARRAY_INDEX_REG: Lazy = lazy_regex!(r"(\d+)<_>$"); -pub static GLOBAL_TEMPLATE_REG: Lazy = lazy_regex!(r"^prev<(.*?)>"); -pub static TEMPLATE_REG: Lazy = lazy_regex!(r"^(.*?)<(.*?)>"); - -pub fn parse_string_to_array(s: &str) -> Vec { - // This regex matches anything inside single quotes: '...' - let re = regex::Regex::new(r"'([^']*)'").unwrap(); - re.captures_iter(s) - .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string())) - .collect() -} - -pub fn parse_value_to_string(val: &Value) -> String { - match val { - Value::String(s) => s.clone(), - Value::Number(n) => n.to_string(), - Value::Bool(b) => b.to_string(), - _ => String::new(), - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Key { - BgColor, - Prev, - Image, -} - -impl std::fmt::Display for Key { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Key::BgColor => write!(f, "prev.bgcolor"), - Key::Prev => write!(f, "prev"), - Key::Image => write!(f, "IMAGE"), - } - } -} - -impl std::str::FromStr for Key { - type Err = String; - - fn from_str(s: &str) -> Result { - match s { - "prev.bgcolor" => Ok(Key::BgColor), - "prev" => Ok(Key::Prev), - "IMAGE" => Ok(Key::Image), - _ => Err(format!("Unknown key: {}", s)), - } - } -} diff --git a/src/svg/puretext/font/mod.rs b/src/svg/puretext/font/mod.rs deleted file mode 100644 index dcbf3c7..0000000 --- a/src/svg/puretext/font/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -use lazy_static::lazy_static; -use ttf_parser::Face; - -pub mod path; - -lazy_static! { - pub static ref TURRETROAD_400_TTF: Face<'static> = - Face::parse(include_bytes!("turretroad-400.ttf"), 0).expect("parse turretroad-400.ttf"); - pub static ref TURRETROAD_700_TTF: Face<'static> = - Face::parse(include_bytes!("turretroad-700.ttf"), 0).expect("parse turretroad-700.ttf"); -} diff --git a/src/svg/puretext/font/path.rs b/src/svg/puretext/font/path.rs deleted file mode 100644 index a6f076a..0000000 --- a/src/svg/puretext/font/path.rs +++ /dev/null @@ -1,235 +0,0 @@ -use lazy_regex::regex; -use lyon::path::builder::NoAttributes; -use lyon::path::{BuilderImpl, Path}; -use ttf_parser::{Face, OutlineBuilder}; - -use crate::svg::puretext::font::{TURRETROAD_400_TTF, TURRETROAD_700_TTF}; - -pub fn pathify_svg_texts(svg_content: &str) -> String { - // Use regex to find and replace text elements - let text_regex = regex!(r#"]*>([^<]*)"#); - let mut result = svg_content.to_string(); - - // Find all text elements and replace them with paths - for cap in text_regex.captures_iter(svg_content) { - let full_match = cap.get(0).unwrap().as_str(); - let text_content = cap.get(1).unwrap().as_str(); - - // Extract attributes from the text element - let x = extract_attribute(full_match, "x").unwrap_or(0.0); - let y = extract_attribute(full_match, "y").unwrap_or(0.0); - let font_size = extract_attribute(full_match, "font-size").unwrap_or(16.0); - let font_weight = - extract_string_attribute(full_match, "font-weight").unwrap_or("400".to_string()); - let fill_color = - extract_string_attribute(full_match, "fill").unwrap_or("#000000".to_string()); - - // Convert text to paths - let paths = text_to_paths(text_content, x, y, font_size, &font_weight); - - // Replace the text element with path elements - let mut path_elements = String::new(); - for path_data in paths { - path_elements.push_str(&format!( - r#""#, - path_data, fill_color - )); - } - - result = result.replace(full_match, &path_elements); - } - - result -} - -fn extract_attribute(text: &str, attr_name: &str) -> Option { - // Look for attribute="value" pattern - let attr_pattern = format!(r#"{}=""#, attr_name); - if let Some(start) = text.find(&attr_pattern) { - // Find the start of the value (after the opening quote) - let value_start = start + attr_pattern.len(); - if let Some(end) = text[value_start..].find('"') { - let value = &text[value_start..value_start + end]; - return value.parse::().ok(); - } - } - None -} - -fn extract_string_attribute(text: &str, attr_name: &str) -> Option { - // Look for attribute="value" pattern - let attr_pattern = format!(r#"{}=""#, attr_name); - if let Some(start) = text.find(&attr_pattern) { - // Find the start of the value (after the opening quote) - let value_start = start + attr_pattern.len(); - if let Some(end) = text[value_start..].find('"') { - let value = &text[value_start..value_start + end]; - return Some(value.to_string()); - } - } - None -} - -fn text_to_paths(text: &str, x: f32, y: f32, font_size: f32, font_weight: &str) -> Vec { - let mut paths = Vec::new(); - let mut cursor_x = x; - - let font: &Face = if font_weight == "700" { - &TURRETROAD_700_TTF - } else { - &TURRETROAD_400_TTF - }; - - // Scale factor (TTF font units to SVG coordinates) - let scale = font_size / font.units_per_em() as f32; - - // Get font ascent for Y coordinate adjustment - let ascent = font.ascender() as f32 * scale; - - for char in text.chars() { - if let Some(glyph_id) = font.glyph_index(char) { - // Create independent path builder for each character - let mut builder = Path::builder(); - - // Build glyph outline - let mut outline_builder = LyonOutlineBuilder { - path_builder: &mut builder, - x_offset: cursor_x, - y_offset: y + ascent, // Use font ascent for proper baseline - scale, - }; - - // Get glyph outline - if font.outline_glyph(glyph_id, &mut outline_builder).is_some() { - // Complete path building - let path = builder.build(); - - // Convert to SVG path data - let path_data = path_to_svg_path(&path); - if !path_data.is_empty() { - paths.push(path_data); - } - } - - // Update cursor_x (glyph spacing) - let advance = font.glyph_hor_advance(glyph_id).unwrap_or(0) as f32 * scale; - cursor_x += advance; - } - } - - paths -} - -// Lyon path builder (for ttf-parser) -struct LyonOutlineBuilder<'a> { - path_builder: &'a mut NoAttributes, - x_offset: f32, - y_offset: f32, - scale: f32, -} - -impl OutlineBuilder for LyonOutlineBuilder<'_> { - fn move_to(&mut self, x: f32, y: f32) { - self.path_builder.begin(lyon::math::point( - x * self.scale + self.x_offset, - -y * self.scale + self.y_offset, // Flip Y axis to correct orientation - )); - } - - fn line_to(&mut self, x: f32, y: f32) { - self.path_builder.line_to(lyon::math::point( - x * self.scale + self.x_offset, - -y * self.scale + self.y_offset, - )); - } - - fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { - self.path_builder.quadratic_bezier_to( - lyon::math::point( - x1 * self.scale + self.x_offset, - -y1 * self.scale + self.y_offset, - ), - lyon::math::point( - x * self.scale + self.x_offset, - -y * self.scale + self.y_offset, - ), - ); - } - - fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { - self.path_builder.cubic_bezier_to( - lyon::math::point( - x1 * self.scale + self.x_offset, - -y1 * self.scale + self.y_offset, - ), - lyon::math::point( - x2 * self.scale + self.x_offset, - -y2 * self.scale + self.y_offset, - ), - lyon::math::point( - x * self.scale + self.x_offset, - -y * self.scale + self.y_offset, - ), - ); - } - - fn close(&mut self) { - self.path_builder.close(); - } -} - -// Convert Lyon path to SVG path data -fn path_to_svg_path(path: &Path) -> String { - let mut d = String::new(); - for event in path.iter() { - match event { - lyon::path::Event::Begin { at } => { - d.push_str(&format!("M{} {}", at.x, at.y)); - } - lyon::path::Event::Line { to, .. } => { - d.push_str(&format!("L{} {}", to.x, to.y)); - } - lyon::path::Event::Quadratic { ctrl, to, .. } => { - d.push_str(&format!("Q{} {} {} {}", ctrl.x, ctrl.y, to.x, to.y)); - } - lyon::path::Event::Cubic { - ctrl1, ctrl2, to, .. - } => { - d.push_str(&format!( - "C{} {} {} {} {} {}", - ctrl1.x, ctrl1.y, ctrl2.x, ctrl2.y, to.x, to.y - )); - } - lyon::path::Event::End { close, .. } => { - if close { - d.push('Z'); - } - } - } - } - d -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_pathify_svg_texts() { - let svg_input = r#" - Hello - World - - "#; - - let svg_output = pathify_svg_texts(svg_input); - - // Should contain path elements instead of text elements - assert!(svg_output.contains("b)?WOC0WB$-SS$ia}4D+aWGU0q#W zRb4$Y&KNV}p<&UDb@dG~FUP#Xn6w?aw=}lQntT1szBd>v{D`rbCmZL^uNipxtWOv- z4&Z~+X3d>Y+?91s8e{wh)bH$G+tugUe{nry5znIhlkPJHtl3V33GJbnZC~EkyY{-$ z*yk8aM|q^Bw`)Tm@}u#6Dq=(LnzNRFJ7xcUjMW}wY|{rTdb*ay4BUw_z5?Z?E0Ccb z)ZT&jI=pABSUa%k-1OYFfbCw!bO+b0@9vuBUD1RzzL8tkc5UjD<;XWtKLhox>$=wV zOqekJTa;l8Qc2(X4Fktsy>}I3Nu3ze>c0M-zRo{YDgQ2!Ce8Tb2H6k^8jWaUiH zJWPwhSlLu|DQa93X#w6ODwpT@BN)?qhU)xYZ6f7_{=?Ul!`YNSJ+S?Qj;P8XSQNp) z*z4OTiMnr|o?nM=MoYq0kFKhGi2Mx@? zy88y1zUE$5G)`uDl)S%OlVOO_%O>TmS}M?`XvLeU=18Q+=+QiehQd%9QF8>OM1S=D zAfBvlFoRz_yVdKtxRu_Q)eYv*`vV51WU#unl)-GuddRSqagFEV?s=jn3h3YclQda| zIK3R1_W<`_;MH6@D6zW3+GRRcM~emj3~V_X<{Bf?UeJz`<}98Y)U?DR+0SZPW|KfY zN*xW$V%Ml?EvsRzYFfuKSc#gBM2cY!2JqL1Zztey1M6n}Y$e_YkS;)2$<`oVkKA6A z&teUjy?JaS-uqb(YRy4e7v?e_sUFnXi27Y9I}LCB;`{Y%9q6D4trX&~h*jW?`dp5B z8xgMu3@cG*0QouW3{kV3<&NmZ8tjKsq5Xum+YITv=JM7)3c!^N`ac;$eIYe`^IS{rG+r>U4`Qr-<>cKptU+MjOEX1{MFk=w|@A z8?izP@YQ;@7Fb^=pddW1L|Yq>Gk_X_8ORsC&>ZAqKC{GM7iwL=uEI)snEi#P@j_n1 zujL;~Ns>dVlCGBSl-`n4?vuC4d*$2Y2jr*ax8*N2)tYshyEKn#p3%IcHE5@4 zmud&J7i#xw4``2Q-`9of@^ve98+BLf?$kY^drB|sYxQU7FV2 zEHw-mE;Q^nd}EZ2ZN^>3Crk!Yt!cig*R;X(lkv)-TMqU#6o5;H&--!Gy@pqxMG~h&mD-8=VOJfej{1|J9O^$WO zR>!u)E{z?CeI|~@wZturTNk%A?oiy@s{|;__^_C z#$OWuoA}?wzmt%XFf*YwVO7Ez33nzulJH%EKQSzETw-pbJFzkG+Qhq)qLXGNU6%B6 zk}uhmoSK}Iyga!-d3*9_$v>vlq^wD~HRb-)u+)Uqsj2f)J5&2pH>d7My*Bm3)UU=R zjGH}f@wk=a&KY;nxTE7f8TWk}ON&WMOPiWDFKv0+rnK#8yVG7CA3nZf{JQbCj(^1( zW-Ya@vp#J7!j@rMW4p@sd3s6us`Ou{KWk64PqS~a|Iz+Mh9~2Kj4Ly4%(y4xk&I_D z-pu$o`NTR#DcJ ztl3#zS*K_HD(i}@8?y$p9?yC)>;0@BvLmuHvYWFvW#5|pMD|xXO*wbwJdyKBt~2+x z+@ra#<$jST=UMZr@;dT1=k3qCA@8BQSMt7f7#$WzzGJSV+i{-bddK6A*BqZaqntVT zJKy===P!yio=U%6<_FzbA3`0Q!=5Xz2wZ2YfBz1`OwYX`$KU@Ce#Qcf#CiYF-HSzX|&rke%l4Vl;r0tUqPI`LM#}!gVb;at6ODb-w zI9l;q#d{T>ReWFJt29)`RHjyDRTfrGs;sMQu57J59Xk$VCiaQc$E3Q3ne*7wYq|#3 zf%hI{`YG-8R(4J8oOxDu@r)_$R(3|yteIA}Y|gAE@Pc_$knfo{XNDE)nL#$ufvcNY zGP7f6b*VXeNK{GKCv(ADlsY=>or%l_4pF4$Xwh2&ct$q3k5Wg2JvN?=$9kWj=Ey9Z zSy&p&4E80lFc!zgf$QgsI$Dgx#9~=0b3zWnKR`s{h>^vx6y{(h-Q8>Z_|{+gT()lg zT7LE~eJ=0s>gIjF^tonbZx>(ni=XSe*YIVKH`nkb;@K*mbHsC|cs7WqXWhoN{e03d zeFBFZSfr;;JPE5L3(=EgHZJhZAtzGWAfAzULY%;?K@vqS?Yr!8Zx5$?R%&6J#Ljc|W@mG7-(SQa4$|R7)phs9PaR zNvH+MnOz}Zmq1Y_e91u*oG1xTlKDw${)tnX%2E+xLd&?s!^Au1@!|`mKb`+L-Z(E0 z$%{k#!_ezMq15)5?4N3T>X)EQf{I`ZK(7-y8a5Xa&7asaD!i#Gyo8fAY8z32lD=w3 zpm8vv6>wmM=0^kRV=7isWN@4$3n{fA&my-U@1H@!>v=3s;dY+Q z9lVH_@oGMmH}QqMm#^lV`5q}nDwF;!y(4R6gB&i$$T@O3_VqjDBl2Tu7o>e>jk6|O z)2!*%ENh{4lC{p-XFbb$(3WoNuw7!iI$cWFrbng6q+8OH)6>!&=~L4?(|a~7C?s#u0WAE-pSo`j>cNZhBdw1TuGvD>R zJMK6}|JTD>(l{9AgRyIQ2LD#lNRb$8vXm;NOBqsuR4LU+tFLl?=`r<9f)qCkGe3`?&bI*TlXwN@pq)Fp z2Q*R6^D!q~n2Szc%r}FYB0=$SVi!rriq63d7GcLJ2Q^J*)7T8i@hxmV)<-ua$CZ#$ z*Mp`e^2vM!uj98se%vgyvWvkL_ON||UTTAjkr=eO|-_%iUeDE?b$ZCiN>zkx@v zPx(xq$`koG?gCHKfcC?|E5ks0G1$?Pusfw<56i`_>A)UW&1#{FO<|3!hA)CnvV<*U zi`ZhenoVba28SGg9&s)^lbr+YavQshUBY&#=HYz#e-wIQ1>ivu=aFeLFPn-$4t$11squt7LaWPq~X# zv3poOyPwsu`@oSNVomH(XfcmNi+POAW`6*u`x7*tKZ4&q!xpfop;tY{I@!z64_;** z>?P<>uYi}m3BLC(bcH_lAvo9H*y-#eXeUpwHufyPkzdcR<2Uo$`S180{8qk;U&=4% zSMt653ceE>^B#T~>=i4ZUH7mzpyj*;J>z{o4J*5zH-gJF@Qe8++zral#kx-h7fR#f zxs}^^I`~m0xIs4dssw%(-@rHWGx<6ETz)nm;Aiknd@VRsH(x5%!uD|tj1Q|!I*aA} zFG2R9-WFCY4YIjz;>w1pX7+J3K11tauEt!_ z`&5xm;y17~DGhZI_|qeUw1^dJ7|QNGMffA~|4YzJLY>pqa13$(sPJ3BJvwYe-so@( zzWZU)b|>cODujI^jOX3z9PWcey^oC-bGQ$D@EExGEug^{QBEOSgiN08e^I0#22Dkx z?PRn$R>(lve+lx_sN=@>O32~&ppVe-PnN?+1)_0&A@X-JhlnYSASL||Aq?{vadO~4 z;9HcP6fDRK4Pn6PwV?BLfB|@uzCj@Rp}q|W+X2f=#1|n$cq$z4%G3vYl z{x`%j`B9b#UZ2F*AUuln`7DKBrsi!&-v8J2D*g9Mt68Qd7hxaEl)pfHE=!bON1Vr! z5Hd9nvP8{!NcXZN`AfWSM)|+gx*p`8hw?(?eZ?}h9*l!%=|1rE^RZ|Cf!X8v5_*0;nSj4{q4iK=5RAC>na|`k;XyX%n_X*1O;=5J^@Dq79Vvp#fgh!!n zf(XUf8|(;y^^(I+7wwjFGwNQ8H1SSaCkqj-L--Eue#zqaGf1C}H0D8~P=NGvYFf?X zk0Fl&=#%)Ds7LfM5y6DKD-aWH&qJElB{WaeCw^xabwCetANbsH@g*YQD0^Uq>z=zWAblyxJ-349aIe?h?a2Ox8cfBxSCwlV(UWB?NZ z`6`1w#U)4^OW9pq<{HRRcS9b?gse5h{A?N5LKgUx>$o1{>fr{+N>6hmdj>Len2=+B z;^B~c9MJKc>{-ZB&q33B9#T;>{e&%j!N2x$hN73@g}c*vG}54LNWU_HEkJ z9gsvS*+00GJV<%Z*w4lodXH(Y{(zepedaPnR6~Q#CdE!^j~7xw#9{VJu{33naY+mI$dcndDBs6f(>*-UG?D7t--cXgjO<8pt;5_?`&)BwZ_HR~sbQUqe>C68xqMd*T#mrF+0* zuEAQomF?&IAb~#tJ$668nqR|z1IzJmAwgcxZ-BgPhje@~(1waeBIbai+4tQ*kPb$6izQUCg`0bRX# zGt~#n_4Vqf^~$IEjq6qx78Mtp8iQ3dQ!+#priIpXmFi}8b#EN#(ajVMD_^^6 zbj?a`b4YH9TdrT%8!>Ce#&x}2{TtV=>DoA;pQW^_o1@g96H?z@s+%ivMWe+<<(heb zLpLv^N@=lLHYH;Ih-T-jz|L2I)z0rME?Bm*r@v>z z$_=^&z5QKh^n|qy7wXzXmabiVW@-;2MAxNYy-V$-OT~KkaE)aGN?nhLOg+H@eUJK0 zPbeCTU8de(70n6^)3~yLENifV>Q|~?tPJ_0*j1*Yx=go5LEV}Vytrz0>y+GeA-N@` zat|h8{Rm90S6bJ>tVq+?A5!04rrRKLRU}W;4A2A&gj6YYsbw`08%H#|Q3ZOVIsqHS z1e__F3p;Zd$Hp^*6L6+70cQ;t>dsOo;B4`k>FgkiM5nHaB}zPHq7v8C8qe=&iRup%MNo1KeU_q@@l07SXR}3f>*R50= zRQe!Or|3f@s1Hi0eNa;ALrGB|q8{}j>M4DQq|%3|DEgojjiP^L>{jcSs`O;``zvcY}B@Di6FX?G(9{c8c6eJ4J4#og%l=PLW$_ zr^p@nZi>>5yU++M3;0)r3+yWC4Pk^;H61R7s=lb-6{)d0o+)+oURXG#P*cCLf4wLK zw1%FweFJB0=owH-Yux&-{{Ho6ZtPROo>*oQ@BK7KYF4>XWG!2N<~lWJqLQgWv6x7VL8siYHGc)wbstgnn5Y9wq`I>uJuZ_^EO$%;dUfy zyOw!1Et?KWl7wcwww_cQ*Vd{PQmo6Y-p5+JnymIiIXt4azPsM5t8cY=<;=D@3tLe+Wk;*k+tPw8Pg{!B zTTZF+wl?daQX3HEAWMC-dJCz%kZL~G(rN`Zc63?2rk2)DNRN9QL2clUw)p$V5mbSGm>w;^9qpeLHL7R0M#$&H( z%lB%X)_SW~li7ta>1$hBy?T3%*I=)~4B(T_e6LQR8jbnW=OJSx ztF5oevjqt!%sG;f!j)FI10d95=$+R39riAoRDo=kLKEV(rU1wQl$ct3SDn&D#3?@a zX5cG)92{EcN0CJT_L{>HIJBw96s6d0ZFwN#DCeLg)q9t9)#ZDmofv}E>W!+MP9sAC zGvtk?w>fx=7V~9BL(u{$R^Xr;fP2lgoz@+lR<9Xo%lF1OXUuCI)GVuO%kW0@*f-^S zW1TZ*x6YWWWTx1VA1m_XoP#W;c7E$%OiZnpchz{!4q9>`t(w6odPL*l0 zHkcrwt7Zpg7~Mwa+3ff%kOnoYNh9EIMDEDRI>i)1)r++Q$+ccqeTZ`| z=)vNIN+8wGYxTz1YpnI&NRVHI9V7=*j6+T*V7NaqkwdJGWi>T5go!u+g0jIlgTuSi zk&+HNkH=tfOzll@4swbUfq06OoP#pO$<9Fy#VO7~EybzMK^?{8oP&Cb)0~3_ipM(# zjTAeaK#MXFUR@_pZnqYA`C?i&`Cey8VSKRgbfqvqq%bR3*sl~?oy;5MIMsk@tiM;r zN&^iYpAF-)0`M{yR$M{zdBM{y3uM{zF3M{yp;M{$AES}9i4 z1Se3O*l7j#Zv}UxS#*V<=Tvn{oL0Ah+>O?hcIr>&7hpqULQDB9{$8m5aQv#a zmk*Y33yrlD_`m?h{H7DDrmH;PTjnfCsLc14|F^23@NQI}h$&+6nbrbpBk=~HcG`{| zjrKfQF60-q2N^Vn+`R7$+jp3)&l_697WIS%*c8 z$v&8 z#p*zr7>t5xFPqm|V6DW?PS8tACK?S+g;$q}w`nK}3Ngwgo=kb2c6GL}1>}1t2YM6O zSEs9!B%RSi4otBJ{G))d*x*g5ZEZ=x=4!2MD;O-~aagxgMwHG;X&F&kGom!`ZD@O= zzo>P3CpkhZ1U?FMSm*RsICg;ih{kqcrJo8mOlX0(5FplzArl#ADVXnqq*kL0oygga zl~#b2rwpjUIcUPBL)0%Q|Cf<;<5&dtf1zT+H}SDbdwGg2gkEfIDx8gAz>^#StW3pQ zg~LX3MtD(26GXu@punOmdx$Am;jsnYQmpdnr^ugy=6GDJw+wY=I=vGSH4zr;fo^L9 zcH;oHnw><@-X@@Vmh%u}4M?;g!6`A@d5DXgIY@|{xm2eSMf0c*CFWBdN}NV@D6znK z7`(6+saB-0$s^U~JPgwyrP`5F@)lA(PI-%{o=7dGdLp%i>WNeb^;wTP@;$GP+~dNp+v8Ms}89Z0xn9e6mU^$m4J&M|3`mgY1 zwbI@;B}o;|SCUj=JL*mjws(Pe6YX87B&qsEN|LHyj4vlE?OmcIslpB=NfmZF4}}R4 z*Q-l8q>LTzRhT>R-(Y)aUB8t6A1s@kA36zSSX?k&;C#65imQ6_XluG;&X5 zBjU+5J?c zYhY6^h3%fRm!uEyJP+RXoINL=mx^b*^ateJCZ2~Ry1j6P$X`NF|1A3V7K6VM`uY58utDIo00KySp42wx*#xj#TNDq4@s2z!oc!i&-3 z3t>mX?hErWCgKBXe9Nf!>qforSKkBQ?NQ%%A-*tdYuMR{2N2e&@d`Cwig*#Az&}xr z+a+Nw2-Cyr!m1IMhZT!=XUKb2m^CaJWfaG$apb6XgZhp!ht@NlP~Y*r3AiwQt;U}r z{wU=AIMQz*ykvSd7(Zcp%!D~KQH*&s-EF$vg!waFYuabpt>$C?0_8|;H*Hqm={uaV znfejnxg_G{h&xT~CW_~&aWmqnYJLskN>iz+5OE#?#Tjaxh8U-H!TdmZ4D!NFIukSg zOtJC%QSq0f-ak>_KTzV}cW)biHoj(j!T7ZC=dcTbv$GKp8~>=rN0EL&$v0w~Fdjsp z_%=1Z0r53rTA6V#(o}vK!o|jIBjR)Meg@S;+^3X>Ut?Sqj&>+EE*lju8TH<(zRywO z;CIM3&O~TXW6!8~QpkG=(gg?<=ZuQeL*7%7jz^$4T8&L=tic5mpW!FNw`%;k8vlLN z`+K9_-%{TL-@T%~KZp29!!g4V#P=b1)%buK--7r$!khZOAF&T%k71XHFH~cC-)cD9 zFn}_O*Qqhq--!36>ieQm`KPJx^nDA$bTzJ1<7zc72VVgW4aK;XkcHT4NH)Y7A`MuF zBVq~kiFKntff(yX|D#f_|5~kwa+GY>e>R-2{|Fbpjw8H*@Djqa2u~n9rp6B;zE^*@ zp5oip_$I{Hs`>j6@7C`i_#!Z$6zjLE@n)nq4d(~T`;pfWF)5-X!b7os&8T?!sP|6w zyI2aUKH28ETw{IFWFII0k{r!x40Pru!M&!}q!` z>0S2;-ainvnC@+*Jfa{XM;T8->LQVUvB$=4$N1pF{K`=j({Jny6W z><=R6n8+cqo3j(*t0D2#5JAO;s27}wlfN}+8P9)+I-iTu&qe7!MCm^S3^CGPzz`!K ziJ?-YX%w*{9U)31s0AJ=Mja_y_!fRjoP8|nI7BadMK81EE08~1z6Q^QqRwnlA6P}s zr=rfMqW4cl&J2;BA<|7E-6YcY5Ik%;VH=WBAD)LqJ2S*{x~Mq|vLWhFeO@hD@th~! zNl($jKSfT2$QdVQEtMoGo+|x@p40-j`5`6oc&S%BY1HvjKb{HF@5S>`%Hi+hc_sA) zk9O)SmBz>$q!v6=1O{vZW9c*tJY9U7F44C(@vV)%l8#YYo=oik4r(U_{yX%1LOiMW z6nI3^^Hw}lm3;mQ(%*{SQ^Z$A@(kn@3mA$C&%8*$P$WvL91`$6E^-!#oCTugC8EtGVg{Fpua<~5h5iA&6KoTyzI2snAHG+}sS=p4 z5;dy?Y*hlPDgk-5k}tk06JJ%~D+#(3-3g{!7*_bQ(4F9MjP3+G;D2!unXZrgsnm^R5YD__-M)Su%g?} z$>=Fk)d8`B2q!p1gm%d1(5O*^?j=W|hb0KD2y@gh6LABA2Lbm+cnLxQLJmSYnn^{# z-5Gd*DcaV4w5_4`;g7@N2{sjPEdmD8EAxcT#e~*{xfguxbwGwReFN_?;&fk#If#Mx z4{}6X5y8xZLB@Lq(=5H3cbU69t$8Gwzr@+yR72;d6v zy5jKI;=Ut%HJ;zn)1Sz`NBR0b0_up7`kr3&0-09w-Vng3J& zH|Ul5e*&};{G5&=ra*5cqV8_!vN}=s1G-Csno50&@IAui{;haECq5mye6zfbV(8|)=Mojryby&V*~ zAG3EZ>Ms+sL^D1Namc+7_z?aE_!tBvPqKI5f5%xNydODt0%IC>!2b<*_+Q4`i@d=9 z0dk)7e~O%EkaGk%cVQM#6RB?i>lMJwJN}o^#;vdjNT~NIN`H^Oe*@3RYq2h_g9Sx{ z{I`&Q6@AYqP<_08itkYiskiX`HK-fGWPBTimJ<>7``=(!qwa4|^8oT+z_&^}w_wNm z0BzkS+QJ;7g+l)-b|c_&`maOD%P6@Gu+R5j%(_v!3h+<$KZAbW!I*TS_I|W`Bi;_6 zEhowgP*1`aRGU(C*N#H8z`cy|SiucoiGu&9kybAI7v==gg8w6WVvZzM?B6PxCGZy9 zW=Z#qM;%G#kUKz;@&YEyaG*namf38M*65NQTASIXN07`u8-LgB+sQX>l@p7HW)ct0 z!GokKBQR6yaYdL6T8&l%XGkr2)v{h(9g{kkBxN<}WQpTi2E_ZQ$nY?HV2m-F^@b#e z&26*T&9Sanvn<C&4XqWP9;K64Y1P49tbwdUW);T*^=L&G#qe`8lBkK&eb2wjhY4o~iS<*_6H@t~6vPi>br@rN_=5_@q|un)5FF{1QY6;1 z80i(kk%Git#7Gmc=g#uXOfhP3O%Pj*Mvje?w0e0aW>#aAHN7S{DC@OG{R$8f>wp2D zWi>IaR@Y(RI$gG|IWZCU-p83^#9ypA%rM>&3tDoQ*(^4_1rJLC9@#W{K0(s!b3UB= zNH?GBd$N(2_?~U#<-WVReN#&Kx7xcV-{2Iq_UZmD?!|{o?^#s3Wz#>oBV%aNzq)8e z6ob}DRxw~li(^%uNs&ga22kq&bzGD|55iaA)&g#T%R8{-1hllG;CkI`jx{HkqjiQ< zhYbrV!J<#F$k|AhnZ;Ae@`dy7i@LhpR$mYAYH#;l&wpc;j%-|d@r7Od&(nQhZ}Giw zj_-$QAbRY!Ou7WnL@|daCn`J){mXi69axr3B?)vd79YqWHa6C*F(f;9woPwNu$3j~ zZQ15BTY|Kt!FOeSJzvw{Sm5jE?Bq8skn$EU_N`d7*w(*=AKlVVvo87)>kZPUSZ@!B zS?3|^O{sGQW?iYX8#gmv!K|asE6S`B4^I;?k7q5Org5oBiIzC7*XzvDz@x;X!i_qh z6F8*ZQIfZUxV0UaY~WC@XB`?$es&XxT_D!MX;5)7F>x^lLz*KqK@Y;V=(DrkE=xi} znHwZ8MyOXtEG>2{xK39-Y59bLwDqTyOrOT5WhUftXI4dy7`jy; zA~^mT!LjY+_d@~^W4ky&(L@~=U_P{vC38HPVLCw@n2upWP*|%VggEe4Oot!?vqA$> zrZ0kj*I8QcTg-2+myT@qdH7w6hVEI2h6U}BzHEd{G0{_MLaUls;snx6Ee8cc(dWc$ z12a$fQfbbNrGY?5;$|>%yW0iYBZ9LK=}Dh=`XYLJ`FDBsz9qcH=jAumOVvKlX5QxH zWmhcn`JBFY77`VR5ke+U2lW#b?3RpThQW1}8CL5MoDwYhM5XwKeLG{ z5++&9F%DgNL8Agv%&|tJ?a*QUx)|Vhhp31cog{menm9>rE^zu}h4a*$d(WfZNS?xp}?sVd;qPQ9cEClO)_VWm3Pu zSpvy$M!lppfgOjD_!b6vIZOhr=yX|4GROzCkq{3r0OBd)UvxOOTy94%)Dv(8Y}pW8 zdaPKff4Uo;PM=QQPT%4i;Jc)>p$`NAY0iH9bKb0Q3Dye;WR~P1yH-{c2BQdz$dk>o zA;ICo2#WFZU;r7IwxQOUVvQ3;K>~y}KGxbSQtH5fr^k(!?lJ2m!E%z$a@wiR4y=z| zw8kaxF2UK+R|fjpAZAACYbW%G5i>IeJiA82qwsJZ59(Hv6Sv}+QXi^fp7dW%Z zqancnG?`>}%ySu$RQQ)KHP?U z4NINjXcnzf6ET`hIa^Syi-s@b!SDHeA-edy(edV+4mf6TLA*zKjobHz?{&A}`2IT4 zZ#pzoNIzIpYY4ky_cf5Bo|VoZ#0fcNe9J_qSV<9E5J!R3g9Go(xF2ua5e_ul*mFF%L+V> z7&8-XY)Cw)tT<|dHYZE5a0CFLIpQ6o&3kJa*x{_83v6)pohE|xV#m(Vww(O z$yrT$og}CeJqx{pII@9+SaY@=Dw|!82`jV3#@Z6h^40Z6&nZ3U(K>er*Q{Q}wH=aU z(a>+D?nOhdf=3U1A;oS6sVMzRWCx9)odC)qdZnEpNF*fYg)p@U7J*)46sD$j2@)Y1 z{OIOiHaHbm5|**QLtML+3~rDtrPnC!H&14wgbObBew)Y7a*pl53PYyP>a) zF>?Z1QAbeNg8-KH+p|T!nU#4;!i|y!GY0X7YhWMYT&59_at7Ikbr4FG`7<*y77KA` z$Yj~rV?u@`ojV~TYjt2y(vcHGlT%8{LdT{`Ie?n9ECuQ-rF@}fVPruqi$=c(IMzd& za(Z%wmH<>60N~hiX>44qDwm2akR(%~E=Vq4XIB@$p~GG8`!%+|)%BbCW1AHTb&+rR z!k~P(N8oz0ifct)3f0O5L?Lx9m*T`qI=K$XR7yKLB>hPm_vm(Zsj}J0?U1gjv~yWN zyARP-m3By`QtIr*UG$M!IrKjNptMIe0f!dcy=?H*MuC&0#9N|`1{hyRvVsUTGl5HH zE=w~aBP2c@(+&a{OY*lQCV4`Ahp5R}&Y?FKO0yS{x}fkYKuolujOt3s zb&>{-k!xZ=8y0Bbb)G573Fb)1NBWfb2oqQ}^a`SH+MggN>J`B}tH}sHB!i6$b}%kA z)*J&t0PMg-GQhC5ttyu!v&&FziG-vpgZg%3y+Ta&jiiTfu2U#V$`dO0!i7Xx3m3Y5 zMt@QWEH#*}f+DQgRABg^EfkB3WD+Ra*+ReOulshp z`QMA&<9+>nZabgidz6&xR#L2~7yk)q8jKLCEh{_>8l13YVVz%qpaD1Rg@OXrR4Y-R z*sx6|QZe{WZbzrGblb+IH~*cdY-vQFv{@JX z+oavn!{CAz=Jpg@V$6{d;YI_tbUqX21c~^_@ZgAxU@s;vf|Y6!L$ukU^n!tmTChd8 zv{~z)z?!|T!wn2W#a~^wtkzL7bu)hy3wH`XxNxDbchMphyVHT)FkDOkFE)pVnKU{r zu&bjDQz!OrO$S(5Py!_U0>gT2*W4a!mq%gQd3oh7-}fc_@5L}=^z->`yw>*!1?k9! z?$&9(Z?>p3I$dxAP@3S(`^Mx1(*gt6;$x#DG+JFkxTM!68znBM7_|7!B(8x@pwsKL`mNaW$O^g@yi(?Rxfeo;I%X{6 zz}6cZ10^9QCYp3<8+Kk;8eBBb*)TUj#&T!dvHv2)-}SwHrMv&c#;Lw*R_)^%SGqTx z7?{d?R&QzbbagGSYCo`f^W2KAu1(eL`vbE7MuCeK6&D9jk}t$~1TK_1yD>Q4Ci6^Gz^DJ zdZWRpH{g&7_C}2sC}BoDH}(RZrVeH@VH-0TB&g^Ro5%(yli|^UgEoc^9l1G~8Dxx! zi35rvh#v&eF@|Mk6zU|8ilBY%AvT(8M&OYDL&ezUQKp&@oPw@$Ko5k2beu~m^l&gp z59GNRPWCb*SQz$aEHQ{!iX@291-56%&ERT6>EtBaWJB6bD1#(=b-lY`@R^2&=b&{$ zo^dR4gHaLQL2J}@9CLXJqv2Hwk3!gMK}jkHC4;42=un_0nB#=ClGcz>FiuFi=!SHs zKv#A?@Az@|TW=}d-77inclsXSQ=RuKUD7zIPeBj!1V+)!Ih-gS-{uAA?y{FOjN;)jq=12Hd2wR5Q(Qg0Kg03&g&J(H1uqRZ`Qv z^)bd_V_TdUhIGWeXy|X6>=A}G1&Qjs5v=JrQ&Q$_hnP3op-DRpV;LS>n6hk1*Ab@G z;iU<4cTjSH2oIC0%PhtEHg~ui`1Hn-^Q6f`ue7&I&Y{Ou-qSDE8_u%@@44rs^(Oia zsdG7a@faGW)Q<&_4li43X9s>sht38@S$|N6q!6XfF8un=*zFA0xeULOE$WPtRQjPk z2HM#xX~(esvpKj)Bus>$IpU3A=G|Is-C}kFhGF0>-5g6MX)Sa^5F+*x zn0W#y)Y~O9G{rKq@!X-}5`t_~LTYwfGY`S)c>$R;bgX-D<}zX&>ROpQVm@ghs`H7t zrqd#f9%i%2G6WSD9YWC`b-2d&{i@ZzA6nfr??1cj zocm|GrP!e_@Kbh6e7E~vl9mqP0FSr&Zc@3o{6{gv%M@yo=AJae0=|$smrJ*wq7G4$ z(#{Te2aTqt(e3OS-It2t(}9avvgSmG;dp{Hd6hLn7b$uraTySxD$~$JgA2w80^|v?*cN0vHc}T9BBXiA_-EaJi(ED# z`g=DoGVm#Lmin&w-t{sV=L2mf#^ z%#Ubi*XX{~u}>1Tt(*WrK2uR^{~Msg#c2+*Y{NWn+$je3}Avc4Gu1c7tF$WMNd=!7uHNv|~XQBuvQs z9dO%N28(@cbW}J_boCH};Uy0)qS0_Hf6jAB0AL+ZCRGw5!4+SHK_Q?+-AuQt1K_Yh zhFAonN*RB?-1kjqCyy>GDVg?e8J3IJe{`;sM=>h+J zr)XyvKNURdgYE3)&-o9l@EjIEE2q1YfZI51rLfM^@mGY&i1RNxzKOvhFD&%Y!0n6? zhr6U~0o!`Lf_Pz@Pf1RQM>BCiHd*I`N4l*tm-la+4-o_yACGc|<|1i-v4^IJ@ zI)C$&v#w=@cl79|g3uP9Lh*8UeGkY=WBxZx&ve?sZ;Ra21#<=9f zm?*u;U`vYxap{;o%m9HF^0+CS>zPU48^)mpk^MxY$%WntO6ZWGP%AdWtW0~lb$n_H zT1|pXPX9%a?U3YgI@$ya3F5*+vU#S8i!vKYrIM-stH~L&eP33T1ko~y$9DS0D|A`G z`zpVx|6o&zJ8z03FMuJ3qxr5)3V!bBa=BH_iM;Y?wZQ&Et0l|?Z5dLfSS`ZZsUB*m zD+cRGQQyF81E&p;{#-Bvz4?n)DoSFg(QX(M@kTI7K zCxFl@r@@K}Lu-|ouh;j*>eawXY2`Qd_1{#MO3l2kdDfhX#U)VgT;;RHj!!Vazcig~ zQP(Q{&Z{^hlLY}_9SS(%i5jT0o4*V{CL_+#9`OGpD$!Wd#8~WXwP!{8cxWz0mIi4L zTUSbQ6ciOX2|66qQq4^A2!N6fRSj5{_-3ZXZ~JO>TQMd>2Q(B6kt}aG?~)gLS3magv4y{-4!^T)ST!+SA~3_Ng2-q|3KeU!6jTjkh&Y3S zp`H9}|3P*12Lqe|TvsDKDClUir}8AOD@i#tZ({FI>FAW4*A}3Bl44Y<=g*b56zlV1b$xCS>k}4~;QFLl*NFA0)Y;9S6zh}rQn5bAXyZZ+6}Om-b0igY=>5ZQfDV$FIEHfC00YQ4)GMF&My2ev%>5n=0ng7jC_ox zBqt`Iop?ye2@sJ}0y9F}tk9Vh^P448Y%j{x&^}9g)5l_leC;873J%RFDuaKvT{UR38bmnNDM3RTBaX6`&87<<-*7o0pze z(YTG@;akA(aCenDWwT=VzY=~MntKhgIcm_WqD($e}bi31&< zlH)G9K9`V?n>TcYq$?@*eN$Xa^H1`83aP$S8zmD?mNLy98=)*&`) zVGg1bFR}<>yLKg*`NQ>3Omn;4pReR+lr;Hnksk7WUe@Hp?Fl+Dl*-YEg-+LEaR90z zEf=a7wk4Qd=r|PW0O#tCg`G@n$Yc_tQ!Zf^5|$_GO=U(FyQRz}m6uYdjg9Vu2i>3Z zD}3wKzDu30t)i2`fX%5-0s9l z1SU}CjC@##nUYgYt~kz*)WL2I z0iZt~H?&uyFC*E$j5-K(aQ#z_iZq+#?ukvAW>6ED!ECa8O)FlMHq9kv*VUc)Dz8Xd zJ9G}_k)geFF^^gn>xqU{SjO1`W4Tzz93Ll$566w0=K3u71LVS$FiL~lhx-WVmR1n+gCFWy)-OA|-@m+cdXr+AB5g90+7a6G zt%7bYWp|C%p8YS1I;1@-b#{Y1Mr+T2Q@|tY5S)yB_Z4pPTfiAhUVpT2!v`7~06g@D zc}ieoPgJ}oGHJ^gb!^Y4HLd8eJ&Wb5(cTVdr= z$N8<0Xh8SS$E0xV42Xyn6bt%)Wtw1LV46o7o@^*Bo%Hur9NtL2OZnUR`Axn)q6BpF zEijpmdkI7dm%;}wz#YEDeLs;8?f_ps+$vJ^hac7bfT)O|3w*nez9p#r=ZSnl!SG0t z3}AQ!wK$~55BUxQugG5D^zrKXEEa^y?EyIdwjw8Ag`Ds971n0B`E>#KhoRgXgz`#$ z+3SlZ%uJv%BfJ&F@p@F`=wtN} zh_WOk29DM3!XF05p)TPN!(*{y^K2Pf^@)|oBe23W?W^7n!y1!)|9a5dz*kg0gy(MG zk>(_CWOy_*!wQD3Uhm5oI z7Y4aAXyH%=Ez$?Hf=m;zw~+M7PtRKq}5UU1>No0E& zwrbq%`@XWIWD3`JlvIB-yWy*9r|(DU;mRtqYUH?@zbjU5r)-xZK)0vr9kLp(!r?HU zob0v07%6#>-QeRM>^+k#KqvS96%3D+JUq~QN%re<`3J(L66~F1Pgx!NSep1K`==N? zR5|4sbd(KL3l|S@DnG&oTIu_~ql4>eN=jybIC=7y4ad332_d+-s&c4an%GQ6P-29F z8`Ax3oZx!wFpiO3b`-k}ax7s<=EKY?f%j(ZtuHOD@RsmhsV-dEIX+=RHqkIOeUab_ zbOV!k>{PK*m3-o{$QL~Jw`1~Hf=JY%9gx0Nb|wBie;YIpufM2B=-^sSYz&+yKqKUH zD{*p{7&e1p7=maZc$jf5LA8RpZ8%BCwx~FIC%ABm-VeGctgJfT*2dkw;}a*oy9mF` zT;8&BR&nuU*WBe|O#*=D#dwaX<7rbkkI1L-AYY8)qCd&HGjoj75xB%nk5BYZ(vHDx<+&>peBGFf)@8jmx@l zx}gB)G}uaTIZe_DokUCw6ch4Xg4RHc2ntEOIi5UR;^FrIH4_|%j(u@pAzWMno-X3z zN|Em%FSO#`ikt6i$0Zi^?#daZ(;Cl#zww!>-;0oB_!8(x`2P-gvasQ)4$O-8AFv91 z3QxySU*>><3%iWvZ78>;+xP+BY5YLRJMWYb9pc^>??Rix77GiI0rzsq+ndg_Nx_mk zOhybCt*aasHE{}~{uB1V;5q!T%Ei03p|3N~EIs>de!N8V*Xf)++u2EbDSBKYUkfUG zQl+vD>|MqBDHK(Lil~f6n!@IKT3}+3HPKNpt8p3U@1$hH1`%*QAwZ1=vfsjbsnr5d zVZu}oxuI|aEh#L800wEro8X0l3q>cL!3SV}J38bDK32uo@=@pS3i^axP$%FNV-XY- zjqa40`Zyr>nIQku1_c5UN45Zwpu;!<6GqB1{*k@|OC-!9 z_}%5c%VFL=-Dg*^NWb2Yj=QFzavxzgG@tHY1lIh=LR&4@|_f8qZ{0R>F-noZM*Quy`SUo7+5Q9m5;R4^jS1)LuBM}<*N@xlex2*Tz3=c3KDw6-OiF)4 zd;j5NU9l>Rd9rTL4vdga)`e5Xs^1q(`29JhT^lOg6E}31y!YNwZuNb26(8?=|0-_w zy{C*#`kSDL*Hw!6C@@PS@-N5#2}Z>Q@=10@eWD1-ITl5X_;wdZn<{<(MM!MWYobhA zNH(E|<+xEa0(dsRiT4R6@5zLe95AU#Ozmu*oBm^eEcS-5kemHp`h^p-$T3R}h&&Vk43sCYG z0{#zF_*VpGm46}hKI8+=-TaY}d$_s^z7SR+rOr;i>?Es@4PVopqjdPd0zde_iEI?@ zBVQT_$-kBIY0t#}AEXlbB!R+qZ^A7qiH>B*TMp;lT6rcS++4!Zq>wHzvW+7pDp&*SHegdWdM^e6P}xyrZIGM>5Y^o}Ruu3uk-ZS}cwS92U!r z)Z}xL$@)W3&~KZNt_VL4^4$jSge2kTDl>-BC zVb1qfRP3GPw6C(`KYlklbUg6el$2kk;OATvS>aLaR8UOFM~)na;Fv?Q0R1LToVbJ_ zrq{xobvm6vXNWOt^ofo#?t<@cCVmKp7ZmY=p$*b}-?zSRT++oun_Mobe&}JM9Fj!H zk4t!>M?ei1UMXl@T=AsVfj|fy3^r3Zg@8?(b^26+K9}$z#_GcHht$7iMx*b(h8bHs z_%=9oJzmUzh9|48Sekqut!DpyoOY`y@}?E{zG%vD_e(h9$1A6w=!jWp=Sn048kwy` ze|kN6M~q42gy!{;(?eX8ZihikI;R_+7s93-OrC?;;IAIgMMSQo#Q! z?|pv4x3i;@Z|HJ2_#Ufo;FBB9pD_y%wy2$08HEXC=ZB^<(t7e7~n4`(JS;q~b zTw{b+OsvJMsPS6i7L!R5CJqxJa^eti3x9s-1m82g&Ud)MaT@X(m88Z^Zbb=uIuNw-QaRf zn%6w*m8yzY1Ni+$&_8UcaA3!|{B$e<&?QzeZh;LOeH4;3n=Q%}zD(d2l%JqCiz7)m zMU}iU-!}Qil1D!O{BwS3ZElJ0*ua1|L!!O9a~Ru^xJ5+v6LAeI#6vzXM6)G$+oKE~ zlkV(Hx)W^CLiO_dww&gu=QA6o`d0HFz_x$2xG#Ot!lvF1p5%V8{i4%BpAMkUC`N?; zvxR4}B5TN&XdKsLROEqx1)+gEF%$PqF+#yTfMuO72m%CI)eA4vCQdgMFKXad_1V9} zx2=O)R+Thkh{NCG^Hr>Kd>u7jvg zH>gzV1!l2_4)Ya~ zZ~U^_cR!Bvwv_swsjkNViFMZO^5Wtbnm?H}v`3ogx=+B9iAnLo5A7FQJKdj-#WPz`ZhuxtCqxUibx*pZH|oKAupN|6x&j6_2QU ze|JXy+N-AKS5HZv+YTc-Zgt|Z5>n~^iFb_>?t{DwB7k~JA;`O6S^?4V%*SUq3U0}n zJv;ZN362?$*STC}Q*zU7%d0%gZMHm5S^1vaEc{fS0#`f#8|dm6a;~xf=dviA>-D_7 z)yd8kdFNk${q@{Zl%Dx=ON*0wQ1JXw0MBFaEewyu17!h^;9GP7BNO|m!ncFXi!rWo6abHruwcTQ9dgOd_cm^+rH)Dt;ws zYnWfzh+h#sk!g$-oK-MboId%lwR~z(l_zQ5!bb2ag@Qi(Kk_Q{7qI6NlML`G8%dEu zepP<2L=j~XpFl;j-9F58=xdqsKq6`&#BVYBt*=AN6K z17R045P z4+5_`J~=Rk;W8>bY3%<$UPwy_#S6~k@An_&yX0}$(R7FPGB0-M%Q)TL)K8niEp4gO zeV?|bx}`ti7-^_tGSQI#sPr)Er{k=hH1l{qL1>=H*O-I(b|oL{_h+2;lSWMC=ZSI& z-%za`2OGO_lfwvTa?4iLwql+{p{Zo=vDpxPh)(4Lj67o-zSQ4 z9I%M*w~78wK(Y?NHx~RuA^qPlM*l-<|J44nG1?ze+o$qoQ4VvO=zopqe+aiG0`R{o z$_cN-`16JI-y_QPYB?;2z@JaTnpDUC-WcEe)$f7-vG5B)U6~((FI#*+0zW8M@I&}l z#y6sWlq>yHd4l-fsKVDz@S*(fAbexYFXY%DeQ1^W2_3(k{fo*m&YxshP_V<0mv9f= zAZ3c@PRQvZ@|54zAXndF7oR#6b?yFZ*mkxB767vcH#bxdkuw7RA*eYmtH8B9vt-vz zByZwZ3>M<|zsS!BI%9~R5pH)Z0JE0iuZCNdvrgq~g!Xb=E*I~cHqG}30Dv~_{?)M3 zJcTxmq*JRtFA!fZ#CSLpKN3oMHRG_fgt)%YD6lo+xy03Et8n!!TlRCFd*VF829#<1 zqUu}$XBa`M!9D`@+<@O1!#!6z zEm~&1khlp0<2|VX5k=^c0p~#|{lXB2-zu1Q-!|(HcK~+G%Kzc!grcq5msG6s%VpJz-Ii@JNUA9(2GN zUSmtEClm9GUu*2A>ag&T;u%l_0=E?LvuY_gl@ax{TmN&#HcwP^baZSqY)&{!5hp}k z+~9#&rmF57n3FQ^DE&i2y>EX#H22?j_>PtG`dyFrG%Z?W?K`i}S2TaVfZczVv>m^_ zDFgNaPgtz@RWtat2Vln4SGMSa5`O7H04@6Q{0{JcKv z!i7!CAHQ@5FbR8|k@fI4z@FwI88bkvI4%*yiUMW9#S$U8xUr>$zJkU21N^?Lp4mKU zq_GM=K?0gDP|1n2S+UU)ew+k9Xhv2<%OCp3kMAG zw>IV*CI%RBpa6?s4h)(Zupq?m1&YI@1eOpNXO4>#zeACr7uP1}rvhC-0HDOyhHC)! zxmhixE!lGi2IgiXku`UKe@V{)k)Jhp<3>sl<@q0!-j}V+&a&AiPh?86B~GK6ZiJqU zTkJEui6FjIlR=WgjbJPi>y8N1%S3#D4R<_b&|C&3MK<*6UaAYKSU@ojnbgl_wRw^= zG8oIq&dAQnL}zwbN?wl$X^yzR~*6jP~)3 zwtd$n{QgJ={E@=X8lH}^@%g^)}xles06ncOEc`DYR!WO4%% zA(4wlh(ZWhwn)*SptwR|wgk6)P5x zuC~}!^Upr-_y02i!F4wznaS{f_xF3h_xgO^`*s&Sp-p%Fx%j`%ch;!?A%Ce4s^{>> z>+%==cOkj=8J%NY=fAV&d{<5M+|niIVzQOh%I}Sm z*;a0a^#e3h$Yn#|qTC!Ug z*e&8ND#AlU+qd_M*VQkN>wCpXBh-dc#mPeS=%Q_wFSi-NS3x64YHTEDjHQJr>16~Z znLqR2qUf9J5Jj4s-sL*s@buewIzT-iVaHJqQk8k+74*4s zMjTN>)C2gMxzHneQXh%A%H)FzJuBuyQQ{;X`342R0q6_mPk5d&5KEITP8oqwOxTrP zFhQkC=`WNsjGR(XFyhqlJF1p9RNmC=X{Z!O?x-(|Z|7fn+n;d^vC(E9qQPH#4B+EV|p`yu^>>jyC))?s~0asi_c>Bfy#@nj30959}QsFG=k2J|M# zs9T@?lgcSW8iDT@W=Iy4B#4<_RAn7c|DwOAzeT$Q!v0BP)=Y4=b%1$CQExuzMiHglS}EJZdO;}OT8KC zYRi^Qk86Kf)YF6Sews1R)ip2>k7+w%YCjD&Yo8%ngxAq5Vu(9*q1S!MS;(6cgp9&s z2Djr>Cs=f0o`-ZaXEG2`%+%*X{55202!_tZ%;lxhN z+KhZCj+)ynCw~%UapFgoWo0%wojROu)!ba*ePx!RraBx7R{DMAZn+%r=(1BTFON@I zeL1ie%x76Fxo}xvpOQ8hO9=csL2s{*-!`V+ykg%C4`kkHhey`U-a(Ukw~wr&?(WTt zI+s!Q(B4fwJ(~vN%e7AyJuGa?*R-ugwA={qcJ^$%3hm0vnLJ)MTnUQdk`;5{;INS% z4~V;#mMU3UI#3ZUb;1EBty3BnEEM-Vd zeB%y!dm#`2aFj1@9LPqoQ8a!ZH@=ws6wI`Kmenk|PV>=vsRDdtWGNR;wkQt55-B!4 z2?ambNXi5MYG541gBXPj^%9D{;IQt9v2fJQh3lk`;fMsV|eeJ^&4`U>=Tk%fgzc_yDV8&Gu&Pxj*Z7E z8eQVm0E{x+^A_P0r8v1*4};U#G2eEDT^74kYxR&J&Vbub3lJa#4M_CQ8!&x+ANM_(E@?2q7LYsH*uG+Jw3cq*n zUcGBq)$UzYyLJV4?`BCTtUM;1bP(@s#yBuC5FG+!6*=b!TCkW0G3V^>G+P+s9Xpzx zoQ(6ph@Bk7^6~unuh41D^UD1BbTA&nKglnTq3-)+-T!}WAJ%!*Ul`_E=`<})Bz7he z`mA4%TK}l;lDu4DVdymK10rDsVXaiG4 zItJ5%{Pg|WS)aoU?7(Z5#&(dqLcfz2#T;BKCH*h-Qpk%kU z0smrbPcgWK=UR{hspC&M8xd1X3Kmj1Ff(O!?5U5V#gV1$7+MV>|w&sN@ zZrW7BbRM3D7a+}sWZ&GNHJy5CEB6o=qi`^yl|w3uYh}kmwhCAfD7<88*FBLt>HOVy zA3qK`Kb?aiaQBMNM;;J$h_+-@I*AUbqYJR7o4wx<>L&r$k|UuY3L&t9BcTwn3Zu** z2d>IUK*U19hr|#Sd3G!C*<~ak+b`IJ!Eg-hT1NDo9uReIPdK!vqhohCyeFx>>s{yb zt@FO`;TO*XUT^GZ%;$?8{YkNFhpYI9@;8q^b)06IiZM}N)xBwWN!hI7C8f;bqm5PK z)o6p9d4>KP^OdC_E0W_|pdo=)BtK-VQ3w@gHY;Wb(q@$bK@*`m+tk(S-SlhmgYjGR zpGX9?L{o>bUm5WR7!Wd3p4Aa3iIN)A3^-#Gm0B-10~An1R$H)rU67#Icg2aj(4oPD z@0b36%O~w?*S3GsHSp(Y)6e&-u@?ug`jwt7Tmj-_q+Mwxg(M4QTG@=gu*|v=OK4Ej zF!rD6O3CCc7)$~&m;lwS2*uV8E&R}Ax;62KmgkiG0;2Xy~&h4 zoQWHFf8cdQ3yuGX=EZ(4HRW*X`O8Oe;nXrhOxY1=KWNN^n^f{QuGH%4&XAOc1N zmj!Eg&@@BRP_EPjLdsaO9&uxWTnvS361h$son^(sW)4+V1fbV=-NItE&XIfonGG;( z)x)qALX-;vz-*j~BBNZ89f@VobBWnz$@drl*uD&V9xM^(12Q5mDKYWfB3M5;xq3=L zu>|f??fLf!Td8*CElgy zrcp^KpGurtp)e=F{7m9J`bxjM#B4Xm)o4>geNDC9VGsJsi_OTC0E1Baz!NY{UQAO1 zn1z$g3NL!ATNq-xk>Hj17Pf9ev>f+#- zTo`@fNCMs#HvU%0J6sEcUxf^<=>bqqT{Ig%V^+6xja0j!Lw7Rdk@KVP?f+brE_ZsLiM1dTo6`js#py zpBx2)SdsT;JOXl!fNXy^(PXde#6uvk2!^*% zu)LO~lqXAJ_u%2+(j;Tiu@BZmP{aJjfG~%83qv+n;#A_cTM`@YzjopNUXvP5C%y%VBF-mRwR?2($>3HJ2Qw$_k$sIaDwek#Lqw!I=omLAmc7PLl)h zRmcFhIB^&oVMBmXhiF8@cGx7~arAgRK~FH?=R7gUNC?)0StTLE-jG(14j4xsJ>Lz) z@M%yN4DJ#g7T)3EJvFb-o%=>8(c9gv74`HSdz%(EH6N_2Gl01_9rAU2l&r1O!b7y@ zTEjM@Pk;aR%_${&+oSz7esyzfXjD4@!_LU9QS_IsKW6lo zS2bUv8s@?}#~8&j;Rfan$Rid#;5u^?R(5uFel`p#ASlBv$s$%hh$$aS&yp4`J+`(9 z`piwLdeb|Bd35WVk#*zUd?Nxqc4k2jM~j6OKi!Cy{Ytg+Ofn`xs4UjfT;K@jklj{Y z6%2SLWy7TDC6mL)$imQk1li`4lid%umAK-HCrfdJHNzKqG0w09Y}#RB%^cr+k5}3Y zn@%mX<1C9_Qp~0>M(;{&`^y%$_Ag52ghMVwM**y6vII7Q=#ABYw7|kxdWj6MViq4J z0#K>;NOwH2%U`>%r@p@DaCh9lD^UGVUtL{1_`~YJePMq*URGAN*q_)D@b5?@;%>Kl z$d|a+@4uI3x_v`sK7+RImur;^=iC~pnJmw;%bQr-m_LY66~1z)Bo1Un!_8-gmGTnf zFl=7Kl`KFbX?FP6$qVn!_;OC|3sj7$LR}1T{{wWCdp;a#Y8ymjlKa)1zgw zOk>&eO-nkK{R2n)`;X3wRqd^>-&+;?3bMU#>Y1rir}}-qr+hwtD>ocfo~F-?5tb<< za)g7psg3tV;bdVomlO$`6_| z?W{0_<=J!XK+*uvP>CE>fzB5EK^>651tA`CpG+pHi1tzp;S>Lglc(X!#zuc6)LZNJ zMj{cMOx??tX)i2O+bDoQ*^_P5U0CP~xr+CL;1aaZR0SViHY}=hlD%wTC})stCqST? zGdpl3ILw=~utMhV;rN#3mqGakE(NC@+yH)QuLq`K0Px|9H(It8m#K@66MuX99yFFQ~mm zL43kHkv7m3VxN>o6LQY$F22tIC(30F^Tw41Qvdym_)NAvJw1E6qyCM4eA9;5i?P^? zkCm3*TgrDRa&!kWSh3>3OTl|7sd7fF;l#?Xhyp2xFQnmf0^tad!T#@&ASf*(L5{#s zqn+bZIjp=zkE6XTC6ufL@0F$>wxNLNCVci686R0SRb3$JL?M~tKqmXKC`ZzCXEUu{ zO}LX(*7;M^S-|-)5QqUk#IAKdv%svRg$7zXD<^QAp`puU#HJ0fXa$E5e#nw|6kq&{ z=8TTUb~TTVHtzy*fc6J5mO3Gh%2@#A!e{AYSqIV$UfAR;(@GEzbipk*a$Qg{dl0lg z3=HfV5Jwvu8xd{y-Arw1|L0c)CAkn>fe96C8Q_vAcblj|FZd?5QrU-GHpelLKFn1W$|G0EakA8-<%z|zFIh^n>`S%7io=bAH}w2p0m~SlUmWbk(t%J0m=$o| zpX?rsG5X09i`M>CZC!rXqOuo}!U@iM_N4Nl4N0{W`z3UOLNdpR&K5SZ7Ilhc%wBW7G*QgVGsrE{7S-S$P1*|mKz{w+I-U>3%W#>34*dUtIUvNkHKfFN{i z!r+V!v9%>*QiRJKD=hC@gm(H5K>_fd zZ{!v{`{VD>hgtyz>bPt-*-(~!tLd^|RZ}N84K&s0_j5jDy- zK7)Xv_6y9t{@G&XWBJ*A7;pNs=|EOHgy-(1<>}9WJV01u6FrErrJtK75!ejuHKV>U zps#^zGwxdExiMvn{Olg(smy2TzWm^VzSJmuzoUt^Q)%Y8oirdn8zZn0y$#@|9mKlx zo#ww!ej_V zF^~YE7gKUJ%{UGaI;0RA@i2(U`kA$LQ7=cmB80LHxwq6eEL^;|F5`Y7@_#EtWPAPMCDj{WKIPvvKo9TeH%Hh`Lq?)7`7r_4Yhm_1b2n4MM2RYkD@XS1sXxMtPJG zuC+aD`g5kv%ELE|K}lS{?(~h1zk2_vLQI&2F`c%4pnrY$6Ca*}@91CrN=PAxhC~En zA@369!YsVPh{3qTEO9AH{5;$VyeafuZ5#>{!nk0tX2@quq@3VCs7*Oi%=mD|%iUcO zQ@$1v1cMNN+E&SB|GXjBhPq+KMD<3LGixYA0fSPm97C@{*{L++eXbVEPEjb6sRau? zv_IxRg5m`W8y1KgMA-L=2Ag`b_{Jr~74%IWN1H;pgi3G!_D`JPzf@knEyU36>diy8 z(Ql$1f7?VG3SSfv z11Q6giU7P)q>9OS7Kk!DD=-gI%oEG->=P^TJXQPz&-2ALJa>q_cSG`!`b+N*<0)nyN|P5aYW8_AZl?FXnTN9I{fI?q8DeHj!l6vcI%?S>q``Y}Zxffq z2m13+r6I!-ZC1mxkI13zKEtJl6fyHYW1mUPq{V_i7O@-+OM?|@pET)d3H*Mcrxld> zNKdPxSlq9t4Z!?B0 z#A?Lrkh=ok7m7N}UX$2__W{w5QjPf5gSpH_FY8dYPn?4M0j{}DtOXnesGpC&1)>UX z)Z22@*o1f;dS8Vy8eV$!JgUBf-NhI( zdT|!uUWIWIPF$jpVJ=6i2|4{7kBobyt_%GSp#G^S)5|qy@aR?|kMKZa3t;(l9nUM! z&qm~K!b-?Pt#x7zu)CI_AgrxITc;yuV-TiuxfjCqB+Od||7{VcVg2k8H;VhjQ!-q- zWFgkT9~6@kr(`QXR}Lxfs3~fZTBUAMFH)~j?^1uIzM%d^{U5_j!$!k>hNlcK8s0O8 z8y6Z^8qYTFHePS^86P)(Xo@kFnFdVfnQk)OZ+hDFk~zdY*LP#fbMJzK)z9*%i4V^0+<8-eW)AetuL$R7zBT)WN7@j!?&9N55l( z<3h)kj@um%JAMW6q4Z zJ?6fcKgE0;^L?y2c1rBov3JEj6#HE4t8uot#JHxo?zq))=f&-eI}mqA-1ig0Caj;} zoA8hL()gbEHSy=i?}~pR{*Cz063$52n((`Xe*Ga52FGtS7^nz1kAri{ZGk7vA; z@ovVKnM!6p<3{laeN#J?Y9x z4^8?$yC?gs?8~$7$bKaIjqGnd@tz9LVo#fAlV^|T9?z4WKX^XM3C~H+DbH!kIX~yh zoV#=Wkn?fQ$GNq+H|E9W?aF&*vT^d9$;&41o_yQnCnolDA8prE^LL zN{^Qnmu)DwlwViwuQ;V*U&Wmjf38$3D=T{{e^Pl(lRsB`BRQ#1Q=W2dG}^-L|9I(_Qwsf(v}OkFW`!_;%8ZlAhm>K)iwgs_Q^ zl=VWHSvRjqJioeU<603e?iS`5t+g)k)0)O6m$-QDj8>O8WB$T3 z6!-S7SueNzx6i({>($UbUh}p88)u*Y&QJeUK|x%T9ha^Rtnk z^Y~fEPw(1IYX)THfBOU~C9p_O6F(v6A+7L}WHZV5CV7z3Bqi|>a2zAIi4kBN81X6!44m{FohYDtQ1L*ER)DQHa<*NB@S>qw-o7B@on zp}E$|#&b-iOe`zi3YkekDM-lTa)w<2McGIe08L1uBs@vBC+YZGNoo2t=F zewQ+aYiRwMhK}P+%JQJRXtX~9y$KXbZGSHQskf)sWNMWRTL5|!wT00v7R}-*@f#i9 zL>*qj$!fig2tY}-S`la*f~9{6tkC=zAZyIRN(vtyC&@foDaftJg{~t$fh4(pxI|#I zq(w-QC%Hx?N=KumKwT-X!Z*SZwWnceYeH_^|9pb$^YYucE!7rU8$}NSH7##HPf};b(ZVyl+={2luJ^sNmWvfsS&ABsm|2+ z)Z|o8>a5i6)c*A6zgNH?pacpRMp!9skTw}3lVyg?mU*&NR>&E0j%<*f@>IDNFvJ0d zrtCw>68+9g?_7(&cfPYsh`0A5 zta-cd?GD5<-)?$)-rL@{la6EbZ`@}j%|c*4gt%6w$uAXy5{|LPD~U>~lBVP-Qrd@KUHp0Zlk_N{wUW{j&hrFP`P&`S4$~3;fp>(<@w;R%47PQBJ<@! z%zTsFAkPQZD`getpjGBbFKD7%=3-8IFc;mB_09t|g@fXwc^65=iq66e7GTFI2Q^I> zv&CG<>kVQF)<-WS$5oJ0*MX)gbuQ!WZ#X)Fbhr}TLcD6+p%Nt~v_*l-9i84+m$s+JH186@KyfOr|7lj>d0(PfF>|v9zYkIK9 zO%pZ33k|ehRLgeg9-Yuy+eHVY-Z|nKaLA3&8qO7GigO?ZZxxq`OT;d*TU;-GDt;!e z72AbRJRlB3Hhx&#FFV8$@uK*>ctQL@92dvLJK*DIf%j|zS2-JeZwvOz3$bTkfZcWn z_TY=K=kCFtdMQ@LUhL#Mv6o+gJ@-oR-9 zjaVw4hZgmm=oT+SKX_GiiI<=;y#ikLXYjqZp)0Hx?}2lDAU25ip`AP}TEuVVjq>O6 zdU>01+ULp6(%jGU;1^eV>axZj_H=tX*1}*1x=ox>Jv$3*kWj(k| zoxE6HB1=H|ld$gN!G)6LMCp<#G8OzN9o!%jdsVDFOP($_$us3S@?3eg+$hhGo8=mC zs9w2Dsez5-aWFotGUY6h^`8XwPbhc3C{zw39vkivg=#jw1>#7geHckVc08S;Wd3EuyMpgx9jWq_x8oKT8y zXCP2~IYQ2l2T8C|`QR`CZLh){J&5oChcvldpTh?rKR+PSB+VhEAHtd@i{p#%T)#|vrr_9H>P31v!9Ukh0h^FAv4Q)J08foNRrKtAx#F{Kfd3I9O| z!90eY9OT!ii*F|dC-QMCBe`^6lFW`O)f#`?&wjgZ7x5bF}B1}ezK%n_5N0~W# zUIofL2k!idNR&?@Y(-eDr!N+6xnIw_1o8jZ<)#h!lva^wn23P()i)7uNBlJ696kS8 zEzhtH`R^jV3-RZA*=qfp7w_+hL}M7nLA3Nfcsk~iV&IZ^0|(_%j2nT<$VCXxfo8CF z*wacj3PQIw|C_p;cP&e~-N1>fux5RUo|! z0rJuiWIYOg=nX?2AqMY6sKGnx`=c1&g!BI*p#EKux%tl!@sn}>-~<2@0r@ITJSP=M z8_UE!Qk4eCQTIX~NQbO7D27CzG(r~mSem36Q>TNBtg}-V2b5A|dDGNxPUVqagD~L*s+wCY_MHV`Uun`a;MlFJhm5Sx%7gkj{Pq zX{Jbg1}UQidvl^p5~Vm#cwc-kCyKJbJ`K6=6-W###UHVc^PUYkuoC+=?dcv!B2&bt zGDrMU=8CB@PyAI*hD5ST=0l=dEl!0zR|u(K4P=^HNLzl0!|NcY)r(Icy-CO|>t%_6 zO;eVN4UkaFAz#iB|AI7GDdx&5NSc3QiR?@{MQnnEI8Dr#)5QYm6$>E|R?8YlEe&E3 z(Ir$7Q~g(TW6TVyNCh<}r9;wbj{4#<5U;_T%y$XeHl>tz?@ z<{n6g4#><-NP4j%4pL`4$(?c;WSBnL56N}~q~lf4c21M4A=|8#>mb2ykOPo>HbV9} z1JciC$U$eb4D=J|W#@}ya*Oy1GVoS;q4-*SBey~N-2thXWU8HzzepOp4D#4sNSG0j zDw82!8^q192Hgbd?;^;*CP={3*-Fp}$#0tQib#1mORv?C3!j2*{{ir$haguT#9De3 zyPOUDW*a1QGkDT%kaiD%2ZxKl<8ukGxm@Li*$(d05^DnfVRy$K#MQF9WZvfz0~4cn7+}pXL3g z8OsLx&*(SLSku!xux_n+#<~^j*7l!ftLa;}v8T7Uf9*!o%-$Y+K{T+gXQQc>-)yzR z-_5mp)mp8pdDGfe`2~fAw)){BhFQyc2F$bdaD z+yz0o#U*O(+7)37S8iInqGw>!n$8nmR zEnP9tb4Gti%g9Gl3ul>HxtgtY7$K$}4eLF6FFiWedq+z2F_fl$j%@wIAI$xFo&I1n z78co73>Pu1#4xR^^2o9V8>o4eUSm~Ijl!Zb9o1!~)f(zn2jQit#S;#N{jSw)nS`PHM>a%dXqi@n|K1w zKg0W{R8Xr$fRn) zGSCtm&(v~k8&?kWYkAh?>oyJWSL^asXXxclU$vRDY^P&Et<{qKt5&Sss1;nhN^el> zgG`;=2PdcxO6Yx1QtLxYQ6F56`rvX}ADqSk=>^JXHNfuUDYQ`gmr{r1$*%8Tp3VO#|!rBcQeP zuUWtGtke59YG12M%sm4G>(1P?UaMVEX5;q(nj<}{+{#&f>&{%O=TvAptNWJ+%9Lt3 z%hqjNsiiip@8i<>Mfp5X#AQ|{yxw(dmRXthD8*nD^(9_ZR31v`L7dmyb9z4nmmHT*ENQN7 zak+3TAHHa=&$PI;*;kz4%WCOf?%KJe*{7uUXovK@?qvxnDL&ES6E*IchhdYe>8{T8 z$sC`ndwH%;$#JK+Q*wQ3j;rrJHQFhvYkZCxS9f>yAtkz|`cS%B<5OyyHoJVGZX{}Y z`h13l&4(35K{LLT{=^i@Jsj?o)rl@7-PMO3(t&SopJ-_AZ#f(zsbsFtkmFN5K4(oc z_2-MJsnH80xcXea#~OTwjMl?hGOVVyx7KH>ZBFs2=`D?I%_y9(v)ScqXh4>?CBfw@ zr&M`Mi|de98W3e6OMi3u^67g%m3*wB*#&Iu>~Z;Q4b9!ianUy$rAjGP+MUo1*MS6J z%NJVH>l2O5J~5XHr{Fzdt}mHV$#Z*-M2cQ2al|N=wY2p0zy-q7(xQ)`#np%LxT{-o zea0MDt;=Uf@4=YNH4V)^v%A`7aaUsoP^CN9XJT{%6Ry5P=4I6``bfh~(7>msPwlSl z^%=8M@Uh0V)3p>zxC8pNhGgIp#><6m* z(ohSPhldtiDV*rvU435|4sB{NMG5YdmTVAlM9v{asrB{s%*^#g=3odemoK7b4vh>6 z%#bgV-Wu^1$@67LLy?RW7jV!Ez<~B7SGW5-CN%MvEyEo_h z9656rHP2nFWhSH`-@*COIfq14&64IrQBgHM*;DPaduYjlw5kt9&?6ELpNzp=sOb%G zq`?FMUDZ1=!{|0LJH?G^fi&oWzEc&fWcu2Ik<Fz0a6Nmq|B|w+C zzQVv<9|||s66yI0vJV-hv$h%Bk!Gns1UqM-0z6tBP(iI{X(o~xdC z15i7A=gxX}J$O+wc0jOSY)$1-I-`N%N-(h)Uo46lz*p0`*r8BS?F*^#^zY1byIfOt zqRpz&#aww>dp;9ZgI?O@>n7&nUDSM^!DVzM+-Jxz#R&Fv`IXf^19vnY+(z? z^-T}-#@N@VtD7X9u|p0_u^0R!kFZ$hi>+yHNWkXmn$nVYC|^cn-Od>GwK1V#)Ys}! zUjuc6+Z$V>CdXIl2`&(*6zFhfj<3qI6XZuUwi7G;hhW2m=K1mgVl5Au$T&m8d=DhG zYHjF5&Tg!MlR|Swc+p2QjDp!ffm2)d5L2+i9eKV|tnxWOkUtmA$!LeK3}xo!_$m<1 zCoI+i-L5+9#sO?C$RUFE%?Fwn<{TEH4v7XNBqbK*9G0BZhy>>>rZV;T)I?<{v4qM{ z;uI=FiKRLBffv>w)r=H2d8Ar$?t^KNQmsg7d2LiqQeHci<5UNg<5VY=<5U;*S&L%b z)F&l+s832PqdqCoOJ!yu(MM${(NAS4v7E|KVgT$V zGhCEf!*Ee*EyG2rb-?kI;R#&NZ$2+RY|xT3kQ^YSQ!!sP-cH9}izJoVs3ob)CN4uC zsLUCtJaxF$Gx?2cY}S%g<18&n#m`1NQ?(|~(UMf)TrEijeuA>ohTA)j-?+W=wImha zq9v*L1*kbqYj3NTqyiUeNh+`nWv36fx1Ha(y&YPTieIE9srbdHIbCb-5-mvuc4|o~ zuq)?q2#dHrQ^H|`qSiut!d})=?eSUqeQH|6=D>!|jvAmMoW7TfGjxBuWZmBm2d%L6 z55f9QzD-70N(1j?lZV&9Y5fChMY;A|(=%|I$kU&vtvO9(tl=lBP+22lYDd8n6pCPq zI2OJ)JQO|&sj!!t6v~Bv6|DUCBlQP(B*elhdo8>n4#3jC6E@cKV5=U0g?u?Iv#p}Z zx)JC0vyiTaJ-rlmdnsN~KF0GW@TiBsIzO*f=sv4!271H3LHGjs+8qI! zO>R9rE#&vY5L$>9{}6I4v1;XG=xBY zAWlYJoGr>0YBO;xM#t9g#=L)}y<0!m^Ywbxk8CFEyVf_Ye?#3n=hhl5r zn7DJyJMQ&xxkfDxu4kQx_UaHQ_Kt}wgWijg&O@L$YfPLP^qz=x3k;uzSn$*nc|!D_*%pVw0yz`ZcOYX_`)!s6l?qg`P&fA8_5rRH)D;4)rD2! zc7&H=^T3#R^_chN`g^w)57%oQc{ewquLTGc&(h;+#8VI`zZ4-~kF)hS4RJEz1aTY! zeUCy2HJi-HGks_Jir!71n@y&VxRlBCuJ%1FFDy$NPgs^V-jUe!CgCCI{g2wa=?_{Q zT+j48+J6#(;z!2BM}poDBYigl#aO?i;v0hAe~R>e1d1=yGN1C-Px0VCva{3^_AbgPdq*zMWhGayTd5LrG<2H}!Uuld>xo)?X zLoLV->RooIE_!lK80Um>j*Dl|MN*Y?!J80IYC#qah2R;ZoWsvO{M^Y;NLa{!nx66_ zJbyxc$rx@qfkrK7C|*2M6sna%vw$?!O5s{5Tq}jjD+ej9+NlNQpwpo}D)?&!f34uJ@ZbS9A}OstLt|7Ux%@Kq0`#(sVOz$qEo1nXaouGM!!oJ`uSc4T zWn8PBYqfJ-_7t$ zxf6aSbno|P@Nan@7x((%QTJQ~XyfoOr@Ps7OIu=|Bs{JpytSnNR}viD|2KLL zxu7vi|0VE(lm27$99lt;3^A`Cx{e?jx{e@(hC2z*T=*VB!{wX~;(bH&^(U3-=lpB= zc{Mx}Ny9A#By_v{S~Nwsx*d3@xuOuxcvIlXN%xQx_~uzL{+J=U6CMLPjUTaTn-Pc_X~2 zEQdhJ$9*LpwuxwE_70c194p}!t;{Q<%Z#+MM}F}G?Q{;evr{o)C896Ix8sM}+5Lm? zEF5lUzhZ>P8N+-9w?o()nt@+II0oB+Q`C69(P1{#Y=~49lT_we;W?>Dv$E1c0+mVh z;%3wcG93<_3OXH5RE>%PMx*RDOQOf(()1j`F!;^h-^g8!poOkXn$^*;Hqh9PAvrY{&SbA=KquGU6&_3+3*LI*LjSkU1@ z@4N)70avrIEUc;{Trrx}d6-FqSv9P%fjrGdtC?s;birsUGUf}T(bR=un=(xc;^J`U zI1%HczYcqdWunL7D2gp9OL3-{op?B7@yMhx%gKtG^+w;NeX`O2W{GtAKgF#L|Gj+} zx_mA=7F&q|CU@5qa53^V)Gd*x*e<_GW_gRaF@fCX=xX zla2A1&7#YI$UggI0qVTW zzhJv;s*|a=)<0SIO+8T!kB=!LTBiuxNxU`yr2^iDLR3e7#8h47EjPpO2>#y+hyq9v zDoo-lFy{;>sKH<`cA2Enm3GsGH&9v1n!X%Ev}iw>I8U?d(Ar+JAyqiPjG78(KR%( z8cjRn^tBr@#rS>E-lDa$U%twF3*gcB7T_CuiwF3gAm)4LgqneByGbz_W1yeMsvs5v z?b1e*!MMVLod%*nFRe*pDwqJ1i5VR4+Gt?{<3Gx7wIpeK46~S^X($4_i&Z?>X#C%d zo)L*1hqjxMneiqy#KaRtfcMDP0bz*9_vTt88SRP1At6^7nNo9so6@rkE)2>{5wNw7R<>y^rQGyq=US#$Kj34NMYBidbQg zG<_yxK;j~tVZqz&w06K)fH5q11o>P4?m@&9W?4MzL?vpE-XCp9*-XQ${v~lOW85V-+5k-4V$c#^DMyFTr@*+e6O~m z11Jm?q1Xec$ygQ^(I$zBzr)gvYz-_{Y8z4^T2BUGWH~Skmp91_*`B0(W)V8KhU5bf zRTAhi-(^HvVSj(TxV5uhi5+}Sn@>sBmq^;H!fTPiK7jt$c`TOFKa9Qbh}bE_;Ee@ z(Ar^oD$-~U`ptMWcQ@}WTA97_x|29SA>brBg4Z&ED$18 z8xS;)B~Jo1u&tv8)PPusfpMO65{Av0Wta(P8H7QzGgis>f3&PUYj#zeu{&$_%;L_@ z?G-ai%g&Qa4}{d0m0td8y^b06_uON-x5OSEVv$Bh4xKbhbqg>h3|+*QGv=F33R9@o zucm)kNbt31x>fu&W3tLp9FCM&ySlFIs!h$Ct}g9bE~A?kOXqSWwtnzQWlH_v$Kccc z74n+xAQNq7lylL480`io(6J`)4igB8g+YuhOoU-Ch|(CF-X+L`=;+nLW6lOY7+5GQ^j)c6KVBlQidkSVme@B#%HT&tzR(zUn{PA>S+RaQcr(=Pa4)f0-2V zB~ZdyzZ&u@vtg{TVum3Dg@!nIpWXojTmj7lDydOf0nD(Dh0WS#vqjh!9qa!JukD|ITJ!>b=u z#@zV2aaaEw=Eho?y|8qTwd)Z#CfHM9OV@T$SVl2jkkOP)$WIVUyp1uA$S{M^6dS65 z&aE(vCTP}5P`J@#HW|%Zu+NbtbPM>SD$VK&s6qOOv48`cts@G`K~z*E>Ch?IYjyhq zQZTwW-6ffBY`I9uH~sG(=xBQ#I{X9cuaU_II@(|FD3NnET-a8yuwi~)_o1y9_E#-j z*qYaUEAelJm2uIa(p+Lr$>dsX2!TpC zBgQg}+i{FLW9C7L)3JTc>>xAGzEKk*U(wOOHQ3q{JUu)wpe@2DAdu?$?$-QrsmL7-l3Xv$b zz7sqzK)cj_H1VKFX*5Co5=LbntYlb!8bc;KshQO=5GVv!uwYGUwlW`vu#B!q4GVPV zmMcH_pyR#wv@Q=Ru?On>Psysf1Kh1nh4Xo}EnzC$D?cBX!U(cNvD-T#4v^9DgE2CK z^lAeHN9I^MNx?R6G(gqfBA|A|Bk`=&6|Ru(s75J zM+^~@J;Ed)6A#NxP#<6fF0v~T{vEyG0EwmT+m!ObkLv1_xWQ+2E;zs|A19s61s^mz%jW8*BPg`OPLhP1d%+hIwHj;ff4du}WMgjncr!K~f+C;K9 z!Da@(B(;ZRa{$Fs1mGe4Hl6fus5hiy%skxjX5XhBUHH9;8U9=RH^?5@=6_skO??ur zMTtW1Tk9G zt;J3Lo;&cEYcNPQZdKnPc18J+?0$vm1a52Xn9x9Ho%;048c(bcCS_>qc}z z?GLa?hhRAp%bL`Fpaaay!4J~>RXmiiUq9AY{NRJdeUJT`V;S!MvJ+D#I{jbD@XjF| zxDNBrCiHF>u#efpLrHf;N3>XZYITtuHmqoSw1WllSO<0y)s0QO2nra|GB5UU$6LxJ zImS;rWPhqm?AYwT;r-4Xa@iTJfjzs4F@CDPa&H2bi7cVR*zM9c_&@Ri{~{%{ z*VuV6TqioW>pMavk8zzo#w*8#zmj(it;}Be{^(s}xSd{%Q7f|xzxXuDa)wQW)}kuk z0{jj!&09t6RXQRgLLsBj?rH?jK%xsQS56|iLv%rV)oF)hW>gG;CSs=5T7ttLdl^m& zv4~hED=L$Br2ebF|6i$XZKY3UXFpdU6O)=`>Efg$f10eXbpItaPlr=go(7z!>*GBi zyn!`RTk!d>3OsWeY{!@53@AVk!FBh5-;7^(=WzQw<@-a2g%#(^M}`hl2jjr7TmBFX z=M1;AU%of=D77Px3_Z$#YUj&!z$a;Vi@XcMY>G)uik}c;Fq^Cp>&*^TfkHDcip+)B zYO(vmTxc?BnjJ7~HfzY{bAZHzxLCC01cK=#AR%ztJc4IF1qdKpsRM_0=m;mnVwZ+= z90n{I#`_F93y{x@#{E;nW7>ybO=A2hzA?DJXSmE2@^k$1r8e#-dE6?|k$N@8Jwc>^ z#ddgGL zbem*Gx;xc1F)@M9LkI~ZmBKbj=Zkdc3brwf3Mc6T={nh!rI6sN#1%YOJEhzI&&)O* zDVfsR?~l~5vRx1FoHG00jMz=GaKvx>36Rpgnpc9nA8r=(TC=^EBWFCWCJm5`Boe0Mn zuokNTuaQb}2!@mp9|5IEodA6i3UEL-DkDb)*aV;*LN-w3dB8P}uLsbkr3=~+21xcj zGq!dJwNMjflRv8jgo?yGO$tq|sgSlA(vNz9jg{Ljk@5Dl{k$GZj{Qr;o>{ z6(x(NJTUqH3tpY^HSy~9_L8S`UcE$?G$sFQaF(pCa=+!CtnU{ymEXWl(vYAVWvI?m1yMlJZVg^USi?vRIg2v8EM+{}LEZ z5^^#Mi{_Nh%Dncw?CkH9w!z!vl7BT_6jz@xX>x96z5f~ajc%P|8+ZrY#uT|1oPU&+ zLWi~sSaZN0G22@gVu9fmJT=+?5TQm`)p=$KJCji9u+3xw{wx;Wxo6yd$7w8N zMhHrgY;GeBFTi+6a=~$1U`Z=oZS5Kw_w8$Mdq~5ik~H{!ViF^>qwbVb{O<*0(*Uj+ z1_udb%araUKq|;RR>1k^m zM+xK9-Gg5$9B&^s!1tKzg3fo!x5wRw4e)Rdl-VWEc( z-i=sylAYL%G&?DrI#|^SIG*5`h%6x_@H1%IS%>b;VYkzXkwdpnz!Cz=C~|(@x_Wi@ zsnZJa!^r4;OK0o0j`~XYlCnN-z|LdG?h9KrPVtFDJEOp%u?H%US%3*Uwjc@oz#zr8 zY4mC`X<8+5XI}Rn7)>3p;5k_1GD5c`_n^S~36d4Be5bpz?xupwuloDH%4}~hc#gQx z@rGGd>1Sc_6gMSL9K1^YHQoKDJDsQi_5uSu>r>&cD?m+`%O~)w^qN%iO+c#SGB0ao zup?cG-^62T<^1i#b$7{&hwjnq-oqSMJGcA?FdZg}y!lv}aG9aQOY)x4%uG6?VC$3S zwt+>6?Ve3ebTCI&CvC%*SLWYT+SZo*+FIG3UgiI#^2-y+i~XBuZvcI0$J&`@TyQ@0HbZP#C) z{F>b5-=z2Ms#~(8t|u8?Q~zR{awg0JW|8g9#EGM3>B1lgCqxXFlO@M(cN z0IO|KmWviOE>hyUx&}Xz$Nj0=c*KREnt(6Cq#yDnSi}`Dk73C zH_Z_slf_zoJLQxAKp?*xG6BlNO3FS1LwC?_32FWgecn^h(xte8!EMrxDjwzer5%;$ zcbtBAQ@4VOsQF=o=md9m^bUObw4_y;@7E8+ zi1qJVnX_DqUq2mVv4d|n5W~`kR%Wksouq%`jD+Dy#cy0PocjMaLHM~Ia7K}*Vw621 zJk*M{Wq?;9;MJK+V0(vyD_g>7`xxzHAg9e5XzR+FJ#C2vJ_hxjo!e^aO3Sv%Iak`@ zVsO=;&H#nri(| z$hHfwzWPExp@i4(mn_>%_T~`(By&CUPpmRF)`P*2E%(QG=YfLT3fkK}&##jm>FNGk zY>G)A3+Y7pZT?EDiy62~-V@a3hT%5%%h&PC$lAXC0OJMLhN0^L zXFd383?%#M-YItc|1pY$XdmMQyTi#Dm>a1hW8DFm%LcR&XKY-+9l%ZQ0OSu)#2!2< z`~hH0fJ1;hvG(y$tS{NR_UTB(rtdF07gj9yj?D#_)h8P#)R&eiH9({ri!R)MK)TY8 zA5ZYVt4Sz?O`2g1J6FgLc!o)q)n=GR;#bec43mbEB9?laq9Q`^pP5WdjB^?+rU}tx zHZkJV1Ns#-0gDY{sKvO#iW8`Szy;erlmVW9+Dm0cdt~ ziVJVD(Y92heH{7tj>(_(^?f$Et!?VBJRV5KQlvsGUgF6ftW{E{q`g7*Dvj^b-6hQ; zjf>`hYYf{R=3M$Uh7n0st|;4x4klkQU$$nY`EQcPCrzpfaI0&XGr;bO)q1I1ILvFW zWnPPX=C#+4%WDauRNMj6_G|LM(sY>@=_JQ?kG{7wx2jI{x95`Yy!cwZp zxez`SN$wvbMmAl!Add_??qle#!zC$j{=iPDd9244!6{%3++HVUWk1u=A&dQ=XJkCv z-IAJ;*|edlCDonUGC*7pz`!FH@I9`Nty|+noKO1*@_B5(7(F%`!%(uMF}TGYUQdFw zm{VMe)e^_4NU2l|FlYrH{6Z0J%^odYLNLN-Dy6_h&2{(@`Yu&L3XU)kH+8=1B-&s?}PBk zdq^r{B!JBW*q?Zf;z9R)BEhq3z15B=9M3`Ok*X<_Zfn3v55^e$*P@C^C6$L+1VlI5 z-=LEabA!ecV~-(^pBVUpK$Qg-qw{C@JCndDnmwt-;|OMU?=>5@C*6g49tb@%B}f8Vj3_3>@OJX)g4fwVfJD5xplp~z<<8H zpyRE#a1mUi>kVkrK~g__w{2D&9h33EY}UNEwTm}sJs<;vSF<`t558}OmIICi0gdk4 z_3NRkpK(U(SxUj;4(@Mp-OQPFybq(>PTb>9#Z6U>QqK_YYMzMi@|LMnDvdNjTqF9z%0U%<;N;%R@MZOK*J@n)W$rHGeKbN zpGO6`61Z2dW{h7Q>_?zuU#CABC*y)8y9t;@ZB{-67sCH<=FK4s1|5!K2H2H==R$OW z3H=B3d>I<{c7?P zajh9jCU6lWuvbNd;zWZErwq|pd{Wg8r)VwG9Y{Jk=Xnp3G04V6$cbW$l70n}Hmm@S z$iH35BnS5Zu1Hy>bEr`$qgyFD|NW3>OdH8QdCBmMy~jJ*L~kN_V`CXXizHD85b;Dg zqjX1j^i8`pjvi%2>qG6^jOVnyD8oLv`pVDzU+S~3e1Nw8jhBFC?-+LU3-%IlX9h+` zUIOe|vf`mh*$1P{^Q!XlnWXc zQTcn6%AkCr22$Q&*-#qBqlQs+cT2RXQ}^#MJ_k{EgbniIsDuAW`oTX5i(qK%wS)nZ zM_rj2tE_C;U5R}C-QR8KDO$O*sAt1~7XSR`?U!HKj^~w^!xlsIQ!i;|LIrM&koF4U z+bm{dwyf_##NXLzU4d8J~Hg!3W1 z4q;lvi9V^3kcJgp#ey_!T*|vGNh$&|og*qD9Cbrr!!}zdkY$lfwIxs^OPspKAgAOz zcUD#HbfzzxI1xXP;s4U%@EpM<;U_l7#%+qzVW3~{AiWjdwbbEQy%m@TjpA?!V+y(g z&+hM5xM*vAYUdXuhRgj`Rr@Q8UEMDHllP)O&ylO*&>i=Of#0P(h!I!>d{4~K{$T7P zds8&7P3bDKQ!X%>Ocs+R%5E^ndCFuF{HD`YwNNHamq~*cDHGp)uSr=pcs)$+ANlE; zAK5V2pNf6=!z}Qsqh*CngD=ri+dvz{69M}z+={>u?Iv>~Bdv(NuW1`EOHl?6RF?YR zFR3`tE6;`_+OrMv*}6J^d4p2EjaSp{l6EG>72lSliNcLJa6DCw(av$vk#Lg45XZM= zG@Cx=$V~Sac#cSQ;XO)z*f#l@sCL?;U>9^}WQHxrl~UfO^i}YVd>i{9?mEMp8*{^> zK|e(PG$ddOX}<44TeO9Tq)Q~|E5#|dyHD?FXqQLi9{)picx;e+8sH$S`IEHkGyi|O z?;yYRU(wqu*U){3zr%~#Ub0Ody67VRLb(q&x%c3{gFcoNV{in(9a=gx3TCN77UGLMfT?tCof%jxze4U*S=)1T5DHXsr(A|1&o0fkJVz5 z7TrF_%mR`rVN27b98SAtNHDTI#k0%&->g{^keZcMBRCJ|UB!YQ)4=7m zpgRVEA=;h8_hic8?OBqUZU?J&8i8|z|DxVG^)jiX(!W)H=kJxRT^Ck$*HJEkJDSYgzIi&;9IDd75d)?)~s9)OFwH5xxBa`9oF*xXv%JL+{BX`cAd#R66nXTW-LqyMwzRaMKSV|is|NA`=J%KIwH@2~W{n5`^1 zo;&%CQ;5Wl%a7C2F38I}l9RhN4aY%r_BaHXhi4}ga{?~|lQ9I^BCmxAeJup84?u5} zn%+3x^#M4)XzreHe$iJ0Umk#ODcN|i624kXE5ZLR+z-xfKjHD5kV}@Ar>EVtV zz^~0vKjInq^Zf`{8$OOg@$tCYE{AJMeV@D~-sP?ICu;5Df603P-*7b+tH*M+biVmr zLR>9CYo~r8GyT^{V|Ln81w}KZaps@5MbG%jWz#bYt25g>@asH`tL5nH|G?1z3}%@v zh1gt2I2t5_u^bI{9uS{&9ho=Xy(@XqqU2rf>GO`%Wo1<^$c>-SJbgyXg!sG#l~q@z zOuUNL8O`KU%;bN~&&mS)%&GCSzf9jzmzi5P{fsNFxI)HO6lNTmJJ%cN;ou0a$Khre z9tjWHf*!@qh*_s&m&FgQU{HPj6Elkno=ng3e=9ru@BO{Hby9UfR@Jl@XLOWzFQ(Pv zMnkK4H2*tZ21tI8mjSaHFGE7-WtsjHwMXVnbMH=mx^`OfFMg3M!B-BuOUCehUYm6M_G zaXA@@oGj@QF`dN8T#taB>geH3+MAqpZf54WS;>2oWaTvzQ?9Ba(z-@APMCoISm^xZ ziLDc0bO6U-h>6a4X8vnF21rMi02%WGd~A3P5X>n7KIVFOF46zfwYAbX{odr{dvVVs zBYwjA%3G`a&2q(r6%(LyB#Q6kjmlfl*S-bqeFrO%P<-FPN~C?miS==O`&Zzb4Elys zSNitdxZjrZx9`XOwv_ujG48j6TyOBCZ*Kg5lB>C%KkzLG&VR|X_}ft6TM(T8k_-z3 zW_^6>$QbAw#!k}o$z%Wj@iG=K`0X>_&{27rnu1-=be~z3g&uR6q&u_bhI!J~HEEvz z>#m4$<#1hD`QU;&qM@Oq%0ZM*#Tgt~J!0sW=Cpj=M+)S-wS27V?{JSH@O=$`S5S|> zKS%Y&_o_MYU43kv`rilDU&Y@Idi|&L`hY|0Up+KV{S!g;WBEIjV(xz{*FT{=7N`$N z>16l^gX-TiPXB|D2Lj_eb)5DGgW7N6?=ZY^|EszGLHY$x9sZ}s>E9pJ|2qB-d~tnk z{Qi;t)tASqKQvN*Jp4kyADN#Fu0IMt_&$Ok4gaJ1C%@1D{Dw5HZ`I-3f*9wT(0c;o z8)tqY`HuAe!AbDD#lPq~=;T{;>3i`8> z#;*JG;CyTcpvMo>(x6Fe7jz;QV7sQLMbWqvPv7|7y~*%S*~0>9}HAm&&U!-?8LViQh_sS2|`J zW(!!Up-$6<-VxIUSO@>WAD{BYir8q}O^fDV1&KAo8-RSSFlY=PP-5G|Wrh1a?UT)$CULNo5C7fmr-7Zf9_a!L+M@5AmsRA>OvRIkgwW4@7g7UM$jz#k- zm75nqu|fGmDDUC&n8HGjF%!%v))6bqR&>oSMp(E=xw&HgqQQm$nJTKf{^ z(KLR%!kk&yT3At0)S{f)Jg02%=JMIiRN9TwztBr#nMe(ZB?Q>L_)PR-4odR^1!Yu0|=G^gy1rp0fR>%DxT z*9_(kZuATVC;-)vs<}`i2cFpHa_|+NoxmA!yf%a>!IA@?OV3-%O zbrnB%gvsPzg~HJld7TtqH+SxNYGvo()=mxMTg6k{MxqzW1T7b8LbC($VK_6@nUy`{v32lUgQK;8Z zq?foZy({5A-i8wHPm^y_8(JMT{>SU6@q=;7M}ay+Tb1ykUjtUxA=Ce>>`H*6s?zm6 z=cd;z-JMR;>1>^(yVF?+Aqh#RLzWJNB_RtWBw^`<5D2Ik5Dir6(YbZ z&%b_WZSxP&`>auEHW;b`@_x6l2FMYmCN=_`<{N%^^>*DduXx$cs?gpS@r3!Y5p@0y z&`sx^M~||3KpiIC!^d7`E&Np_h$BDe?XVB##V+u6CG}Z_y~x{P98ocj_<^3G7|Y9S z8O|rP$&84bN?za+4^|VTdof2^6Jw)sLglLoKQ#SMcu)dAp zdh>4+O$iFejCGsbra%>dI_Zlwlpi@#u6@nJ@|%6tzF}kAuL=*zFM7&CwDRD1D@kUQ z6`&OZ)$Am5`ZTa%?Q6Omz>=lozudoS+qj|qP@(*yCr~zH-+H3P_?(ahIwf%u;G5Qdi!!5{>*aGH>4{d*BJ?3qzEAy4*kQfs3Sm%oA7?S_piO|v(G-m zTXOnm$_~*EbViCV3uO*Xprhl5P7PUVl1Uid=1IaEL*qDZ7T5VD_v z6bIow#NBmYjDy15E+$=1GYxjg3A1<$mo$ z|I8?>2z(KsF^zr4=kwWE8@*K3fz{ETOzg`U>MYa2UC5v$j2}chkdWg@LINe!3X*9Q zZTNn;E+bP!J`6zw3Bl^fpoDzHq73osc9`68FAC>j*QK;@bA`iE(Hu^@G_9t&!fvm) zwfuMGco`dPw))2EB-W5r-B@q8({Jsyq!8;01iqw2ki6I@`BoSbUL^pWmCccj`JQRE z1dG*|!V@h8If$5)c!78eZ+4qvvEqpmx1p+wQvS9jvqU)c)kI3bp+M}KBxbcrwBv$y z8{pN(2cWp9Fc|RX= z-nIFW@n71J`hlu--&U=wAK*<*cdV;h{Ql~rci`8`nM?jOc#M6qWNF!bGv22aiz2>+ zqVH9v@7nUWH9ilDKUAT9ztQIXIHl+IO3VxmNdVymn|RfdDhxC)A)&|8ae z4DeAjepk9~(z&oqD0|eCE{feyh2RkIl2BVIuqFQKc*j*(bi^CcB!2e-?G1o(B9$2L zY8Hk@t8Ex=$A81@p|xBYm_K7+@j&hTf$;6ym*ZE*9k8t_tif+#0K zd4!(kC*cYv)ia6YxS)wbt@KYI0);Rgh?samlMi?TY1egb+&FaLhFRBNKlI|#kKmF< zRZ~U(hz5H=^e=l@+!yijL})Xr9SR|30*nWm!1FMNq}hRPGyxkM3K#8C13et7vG_-T z36;(%H;3L;xG{e7`TxPDkKg=F+F^ypwhLpMp#bq3X}YddgJL20NCiGAD$oF41uRA+ zQV~wK1$GlMt`a&t>8O#=|FeslROk#YdP6+Okx;>qP7+e={$-=1qfxw<{-OWWv;C}i zAlScfVSjLdg;q9d=U7%#lqiWIt53B_jf*ror>@a`)74c{(zUpl|8i_uS+|EDo&hg2 zQC`EXz#K^wRv7}+YTsl%T1GtpVvjDGoGd3IfS?fK%f%K%PoIvSzC<6V1q~1@R<;83 z`^s91F&#K!Re9X1<*;*1K#G8lA@&mcJ8q)ua{(cEsEpL)W;gD*J90N(J0d%F4DHwv zx%;jreC~iP1sQ^gZe`!%7g@c!ZApC&xhV0F6Oeh@haRVhYJ!3A_%Zzn7KN}d2vL=i zV?G}peSw|Ra$Xo6W#6utw_*Y5`i06dw0%sp4SSl(d&F=;Y^uBuUR)vZx!QRp!?g-= zOS}uIRXk^3PtVaF0s}&;t?2Uv@Em4{-Np?BZpT^xhXw=>B!(xfhio9r3SZ#YanlMs zO6-ES#R#Y{@FW?YU=)I?M?YaGwuH}WA8%-!Ucaa2JytZ-=$p+JhF9Rgitq~j-wJW} z2coA1t9J1&LEoV$QCOk)*+J1N>OMrp66^|L zc(U!7vRwFCL;#BXHaMgqXn=1V&S5QWADegZhRixctq%@s9sD2h$(4C=y0$lQX_Ed%iRNrAY#?W`35KO+5q;9m@+Wcz%cHA zn>z8IMSVoiunRTp+L; zztGOk1-4G*+&N6Cuj{L?6JxuEeFQR1G`4sShzHP09z=3;iHZjW5J(D6h+9wKIf13Z z8XB}??3p#a^}TVPofZ8i%@!_krFr3O9Ww1VW7CMg82)t zd$?&Q`^Uh*8*jiih}W8M>|P?J$U}TOJEcdVnt{J&fj@>5$Oi@0VF)BsSE7O{Q0UJLXgJ^%Aq^SuIiQ3jkb`lYAx%b+ctjW z`;uQj_A~izB?N$q*h$P*!UO{EL!nm$LI6YwG)}Ohj!7kj@VKND_A=cJD6Po&Hed?7 zp>k2Ei;9+EeM1iKUK0+lVd_^!n>QDIHMjSlr8EB7JK#Uqzw&Xvu%CTk(J|avcukQ_ zg>*k<8z`bOw8=b$S*Qw0Z#UqPLdfp~?2%8c+*{gq{*ksn`c@6_fmLHqU?&&So@HJF zAvPjw1bP(~YX}__>4@3|JQ4CICKwl4b_!+LAxDR@>;OiK6NrH+r$}^Vs?(%D9aZ53 zsqjQ7_CG}?LQcuYxh!*7<|Fj}wdbIJ0NDs^wh!3|G{Uc3Icp5-m!8B*(L)^oR}_6h z0uipigz)|Gtg$Zapdquy3>e2J)tglE|Ja1>5LDJg29c}AnVX8uRBp3k5zS0T34O4@ zm!DuXd9zUeU~G%a1VanV9~c%UDnX5>(9oR5?1tA|-(y54e}=PnG#~}ZPESA~2UQCyt(fjDGC(i`a=^;Wz=5Qvm&PT9 z|J7AVBE32#F(mMi%}(|@cWm4|C&-BHAw;o7NJ zM95kHzn*L`So$ViWifOvU%ouzpE02W<&OVj>cZo0_NXa}Ag zJI_5w*`R1sY2oYnEx5?|oS9;!fnW&16jLzZ=II>)ZTlQ{)9YRA;LIZ(?`n56v73sz z7SALpsVv5rjp90FCKEUtmMT(hS_uG$jPJ`9QwRJEfE}l#=6j(6G3P1>DT>JL%^2t*DO2hMY-zo0Q5Odzs49ck1b&MFi(k&5oBV1tqd7HPgH z$dbLW0KIYZL~meN;0*wahPYwU?Rn|sfs$8x)bqR^k`J0a;Z4Jj!%$obxL(#uQ3dkI zO&KG%pou|GqjXDn-9THgNn7^4SSRmA#M?%-^YAJG zG5|^y*`{!9Y=>od8Gi~D-)=N!l>{&ZDh31GSxJ1#&cz!=+oPMF9>3$Dpn%pgJX#yj z3CI@8j%2`PLXQFui9Xoi{jE^j(6g05=0mHlpvfIN*FJJh`^e5vZ8fXTVwKw4nNL4< z@G;UkAxu;U*4u1&XjVrCu*#~&bQh51EUMp^>rJp)JZY$0=2q15p^`()HH%fX00In^ z?*=OtWjzZCaQBddY>@U;KqN9JCnt~-$j_r9Hc`<^K8X;eSyGXtt~kL3j=BsuDrn~b zUtFeC40G4;@NZbrZ~FR<7p(GERcS5N)vtfen!SPDnVC9*`u1mYTE1S0+U)sp;Ol|E zrQcmRZNHoJJvkJJ`~kPyo$1bWrn+2IT7)=+jA5B5zGTW{VmWg=gIMM&emc1R#C1=+*1t~M z!3^cMlq`AW%yk9Jo*!JdyJzYCO{cfM(!;db0sq_{?Zlb36+X~P-i5`WmB!Q|Fd%K< zPDFG?X`KQm2bI>zB<}*CS%~_xq@*MV;ktsE^k5|^S`ILh0|QSBE^9u#Wfq&)|8#%< z(}%O0*x=|@+s2w{%L?}ESLfG81UKceO}KL&3P7HVR1)JPc&Gw00~qJouw(#%TvQOq z2l7&`Hzx@NM*#38gd@)KFtND>o~?AOUo#~#G>|ZxR@|+qLh+-*iT2Bx<^)={fFa7whAY{Z$`i~sv7691YIK!!ETe4HY{stP5D#$8 zQ!xiJv!P#^AuG%j0tWj|Dy_(jgr+*A|BqLqyYlnUQ&7#I>`@IV8BBQ+Ne-3La)$z_ zuVP7$_ZoSr9vXF!#V72zEe}m=cVAn0E?xyfcy86WrE^ zFOj{ivQD#^T)c3q+08by4Q_A?R2F(FrxYyM7eJqi0>Q^bTBfg}ot3n=Yafhxz3+Lw z_Evj%R!M%wv{shQYSypU-df*Zc&Vu9QXxY=c(C4+NS*`4iu%<&SV4D z)9h(>t75?F4sSJ<6HCIjL^yO3?!cliTseK>2f`3mGo%P2-rgMeq&<{oPOe8sk`bHo zP1I~*ry6L)c)=9YHw1C{9&&VFpP9L(*WUeJ?QUQGuG(&U`RwM|Wsar1&$Za=UF`af zxmtJbY}GD&=h9**`}mINEkxetBK|mL9{d%ezyII$I}mKbqMo9)GyXtJ%L6To>?3x( z*p1aEs;f^tY`2fv?X>b??CwNAA=4pZ7h)4j5mo)_p){wjqMJ57#jYaMt2pC6V3m0#7G1;>4-CFAmm_Ji7cidG@HlOD3T>^dyuwv@ajjE`XXs zs7_i=;vAuH}?u;rwU{Rw*+ zZ8{aIi4you6#PfQJ8Eo6%Yj*{$YKtWjWD$;IPU);hcEen$N|i}(S7)jK3LuA)m=Ar zuU_4K6ZU}B4)9&Ev-~5`Ll_RkRdY#?g64!Ta&99V0?N~6SVm&h&@i>H9k}+|*RJIs zH8(dS3vg(>wRntHG(}x2eRp!0r~wrX{Nh@=64zbLU7&)Zml=Wv8#(`E1aqr{;+NtI z+|F&<)8wDUmtM7$a4#oa=MmSXP^CE3EGQ+ZvCAu6Ewq>Z|yvg$r*3ZW+@q@Q9!$QRx%Z!107Nk}^e5#Vi2@+>moPb{&o(orG-7 zL7FX#Gb7XJFYskf`NQ(#BvEK0h-V;eN-l$y6*V{1G$pkpHSueTi@Pc;7E**_VeSVF zuL$YTET{;R8UeQs(!|I?41$14gHy=}#h8)Y#n*(~)#WW^KFG0`wtQUPzL~!yKYvM` z)f%)~dHt}*GwdlfmzwcH58TU_;O>9H-Dik<1LaBdB_m_|`-Xv2_><`_44wkopi;Xx z^3?Ph4M$4(t&Jt64W(%FAiqcSsyd#1@PnMqU|s+dW4eau9D0MrHt^}V_WcR&ra6_M zv=W=9O|U5(R=(ZXQK*sc!ahPv#g(!A*xA^Jxblbj+awrtx4*Ic<+9aV8bRwfv7Fdh z#GGx)2J!r&G7alpiUd)Qb3qod1Qs834zvUGJ>`C=gESf{SRph1XJWr8e+9%ph=-pD zOIUn}OmUuozXghr=~KCd=d>mi<)72H>|992_~?<6SQ9ABfq%SAQ0OWZ-~{N7LLN^L0icii=F;5a?|IiR~Ww4s)LzysPhSQKKd z!2Z#WFQD^Nm0WPdnJb*1+HwKsUl{BSvHzy?6XlLg*nNp|)d=_Q40@h2LMnKKxx}#t z7#h`&)d3Cf6wbS!aPZ_YV2@b94JzBiX2*~5ut3ez@$&R*cd+-@%DBpzYxdn z#cuNPV{uyUn?#EXz$+(l?ax?f{JeVv2Bpe&vHRo4fOYo)#vMDGGgJH+cG-RtvVwLx zr+NbUY)jY{%wU!6WQXY(M(1ekrPx`uaH7u)A(>%5cC>|cq0_Wq4?05p74+KJyamet E0N2upt^fc4 diff --git a/src/svg/puretext/mod.rs b/src/svg/puretext/mod.rs deleted file mode 100644 index 6f337bf..0000000 --- a/src/svg/puretext/mod.rs +++ /dev/null @@ -1,37 +0,0 @@ -use serde_json::Value; - -pub mod constants; -pub mod font; -pub mod parsers; -pub mod render; - -#[derive(Clone, Debug)] -pub struct SimpleDOBOutput { - pub name: String, - pub value: Value, -} - -pub trait TraitExt { - fn get_string_value(&self) -> Option<&str>; - fn get_number_value(&self) -> Option; - fn get_timestamp_value(&self) -> Option; - fn get_svg_value(&self) -> Option<&str>; -} - -impl TraitExt for SimpleDOBOutput { - fn get_string_value(&self) -> Option<&str> { - self.value.as_str() - } - - fn get_number_value(&self) -> Option { - self.value.as_u64() - } - - fn get_timestamp_value(&self) -> Option { - self.value.as_u64() - } - - fn get_svg_value(&self) -> Option<&str> { - self.value.as_str() - } -} diff --git a/src/svg/puretext/parsers/background_parser.rs b/src/svg/puretext/parsers/background_parser.rs deleted file mode 100644 index c19a174..0000000 --- a/src/svg/puretext/parsers/background_parser.rs +++ /dev/null @@ -1,39 +0,0 @@ -use crate::svg::puretext::{constants::Key, SimpleDOBOutput, TraitExt as _}; - -pub fn get_background_color_by_traits(traits: &[SimpleDOBOutput]) -> Option<&SimpleDOBOutput> { - traits - .iter() - .find(|trait_| trait_.name == Key::BgColor.to_string()) -} - -pub struct BackgroundColorOptions { - pub default_color: Option, -} - -impl Default for BackgroundColorOptions { - fn default() -> Self { - Self { - default_color: Some("#000".to_string()), - } - } -} - -pub fn background_color_parser( - traits: &[SimpleDOBOutput], - options: Option, -) -> String { - let bg_color_trait = get_background_color_by_traits(traits); - - if let Some(trait_) = bg_color_trait { - if let Some(value) = trait_.get_string_value() { - if value.starts_with("#(") && value.ends_with(')') { - return value.replace("#(", "linear-gradient("); - } - return value.to_string(); - } - } - - options - .and_then(|opt| opt.default_color) - .unwrap_or_else(|| "#000".to_string()) -} diff --git a/src/svg/puretext/parsers/mod.rs b/src/svg/puretext/parsers/mod.rs deleted file mode 100644 index 8d6e8bf..0000000 --- a/src/svg/puretext/parsers/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod background_parser; -mod style_parser; -mod text_parser; -mod traits_parser; - -pub use background_parser::*; -pub use style_parser::*; -pub use text_parser::*; -pub use traits_parser::*; diff --git a/src/svg/puretext/parsers/style_parser.rs b/src/svg/puretext/parsers/style_parser.rs deleted file mode 100644 index f8464ae..0000000 --- a/src/svg/puretext/parsers/style_parser.rs +++ /dev/null @@ -1,117 +0,0 @@ -use lazy_regex::regex; - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum ParsedStyleFormat { - Bold, - Italic, - Strikethrough, - Underline, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ParsedStyleAlignment { - Left, - Center, - Right, -} - -#[derive(Debug, Clone)] -pub struct ParsedStyle { - pub color: String, - pub format: Vec, - pub alignment: ParsedStyleAlignment, - pub break_line: u32, -} - -impl Default for ParsedStyle { - fn default() -> Self { - Self { - color: "#fff".to_string(), - format: Vec::new(), - alignment: ParsedStyleAlignment::Left, - break_line: 1, - } - } -} - -#[derive(Default)] -pub struct StyleParserOptions { - pub base_style: Option, -} - -pub fn style_parser(input: &str, options: Option) -> ParsedStyle { - let mut text = input.to_string(); - let mut result = options.and_then(|opt| opt.base_style).unwrap_or_default(); - - // Remove angle brackets if present - if text.starts_with('<') && text.ends_with('>') { - text = text[1..text.len() - 1].to_string(); - } - - // Parse 6-digit hex color - if let Some(captures) = regex!(r"#([0-9a-fA-F]{6})").captures(&text) { - if let Some(color_match) = captures.get(1) { - result.color = format!("#{}", color_match.as_str()); - text = regex!(r"#([0-9a-fA-F]{6})").replace(&text, "").to_string(); - } - } - - // Parse 3-digit hex color - if let Some(captures) = regex!(r"#([0-9a-fA-F]{3})").captures(&text) { - if let Some(color_match) = captures.get(1) { - result.color = format!("#{}", color_match.as_str()); - text = regex!(r"#([0-9a-fA-F]{3})").replace(&text, "").to_string(); - } - } - - // Parse format specifiers (*bisu) - if let Some(captures) = regex!(r"\*([bisu]+)").captures(&text) { - if let Some(format_match) = captures.get(1) { - let format_str = format_match.as_str(); - let mut formats = Vec::new(); - - for ch in format_str.chars() { - let format = match ch { - 'b' => Some(ParsedStyleFormat::Bold), - 'i' => Some(ParsedStyleFormat::Italic), - 's' => Some(ParsedStyleFormat::Strikethrough), - 'u' => Some(ParsedStyleFormat::Underline), - _ => None, - }; - if let Some(fmt) = format { - formats.push(fmt); - } - } - - result.format = formats; - text = regex!(r"\*([bisu]+)").replace(&text, "").to_string(); - } - } - - // Parse alignment (@l, @c, @r) - if let Some(captures) = regex!(r"@(l|c|r)").captures(&text) { - if let Some(align_match) = captures.get(1) { - result.alignment = match align_match.as_str() { - "l" => ParsedStyleAlignment::Left, - "c" => ParsedStyleAlignment::Center, - "r" => ParsedStyleAlignment::Right, - _ => ParsedStyleAlignment::Left, - }; - text = regex!(r"@(l|c|r)").replace(&text, "").to_string(); - } - } - - // Parse no line break (&) - if regex!(r"&").is_match(&text) { - result.break_line = 0; - text = regex!(r"&").replace(&text, "").to_string(); - } - - // Parse line breaks (~) - let tilde_count = text.chars().filter(|&c| c == '~').count(); - if tilde_count > 0 { - result.break_line = tilde_count as u32 + 1; - } - - result -} diff --git a/src/svg/puretext/parsers/text_parser.rs b/src/svg/puretext/parsers/text_parser.rs deleted file mode 100644 index e19001c..0000000 --- a/src/svg/puretext/parsers/text_parser.rs +++ /dev/null @@ -1,177 +0,0 @@ -use std::collections::HashMap; - -use serde_json::Value; - -use crate::svg::puretext::{ - constants::{parse_value_to_string, Key, GLOBAL_TEMPLATE_REG, TEMPLATE_REG}, - parsers::{ - background_color_parser, style_parser, BackgroundColorOptions, ParsedStyle, - ParsedStyleAlignment, ParsedStyleFormat, StyleParserOptions, - }, - SimpleDOBOutput, TraitExt as _, -}; - -pub const DEFAULT_TEMPLATE: &str = "%k: %v"; - -#[derive(Debug, Clone, Default)] -pub struct TextParserOptions { - pub default_template: Option, -} - -#[derive(Debug, Clone, Default)] -pub struct StyleCss { - pub text_align: Option, - pub color: Option, - pub font_weight: Option, - pub font_style: Option, - pub text_decoration: Option, -} - -#[derive(Debug, Clone)] -pub struct TextItem { - pub parsed_style: ParsedStyle, - pub text: String, - pub style: StyleCss, -} - -#[derive(Debug, Clone)] -pub struct TextParserResult { - pub items: Vec, - pub bg_color: String, -} - -pub fn render_text_params_parser( - traits: &[SimpleDOBOutput], - index_var_register: &HashMap, - options: Option, -) -> TextParserResult { - let bg_color = background_color_parser( - traits, - Some(BackgroundColorOptions { - default_color: Some("#000".to_string()), - }), - ); - - let mut template = options - .as_ref() - .and_then(|opt| opt.default_template.clone()) - .unwrap_or_else(|| DEFAULT_TEMPLATE.to_string()); - - let mut style = style_parser("", None); - - // Find global template trait - let global_template_trait = traits - .iter() - .find(|trait_| GLOBAL_TEMPLATE_REG.is_match(&trait_.name)); - - if let Some(global_trait) = global_template_trait { - if let Some(value) = global_trait.get_string_value() { - let mut processed_value = value.to_string(); - if !processed_value.starts_with('<') && !processed_value.ends_with('>') { - processed_value = format!("<{}>", processed_value); - } - style = style_parser(&processed_value, None); - } - - // Extract template from trait name - if let Some(captures) = TEMPLATE_REG.captures(&global_trait.name) { - if let Some(template_match) = captures.get(2) { - template = template_match.as_str().to_string(); - } - } - } - - let items: Vec = traits - .iter() - .filter(|trait_| { - !trait_.name.starts_with(&Key::Prev.to_string()) - && !index_var_register.contains_key(&trait_.name) - && trait_.name != Key::Image.to_string() - }) - .map(|trait_| { - let mut current_template = template.clone(); - let mut parsed_style = style.clone(); - let mut processed_value = trait_.value.clone(); - let name = trait_.name.clone(); - - // Parse value for layout and style - if let Some(value_str) = trait_.get_string_value() { - if let Some(captures) = TEMPLATE_REG.captures(value_str) { - if let Some(value_match) = captures.get(1) { - processed_value = Value::String(value_match.as_str().to_string()); - } - if let Some(style_match) = captures.get(2) { - let style_str = format!("<{}>", style_match.as_str()); - parsed_style = style_parser( - &style_str, - Some(StyleParserOptions { - base_style: Some(parsed_style.clone()), - }), - ); - } - } - } - - // Parse name for template - let mut processed_name = name.clone(); - if let Some(captures) = TEMPLATE_REG.captures(&name) { - if let Some(name_match) = captures.get(1) { - processed_name = name_match.as_str().to_string(); - } - if let Some(template_match) = captures.get(2) { - current_template = template_match.as_str().to_string(); - } - } - - // Generate text from template - let text = current_template - .replace("%k", &processed_name) - .replace("%v", &parse_value_to_string(&processed_value)) - .replace("%%", "%"); - - // Generate CSS style - let mut style_css = StyleCss::default(); - - match parsed_style.alignment { - ParsedStyleAlignment::Left => { - style_css.text_align = Some("left".to_string()); - } - ParsedStyleAlignment::Center => { - style_css.text_align = Some("center".to_string()); - } - ParsedStyleAlignment::Right => { - style_css.text_align = Some("right".to_string()); - } - } - - if !parsed_style.color.is_empty() { - style_css.color = Some(parsed_style.color.clone()); - } - - for format in &parsed_style.format { - match format { - ParsedStyleFormat::Bold => { - style_css.font_weight = Some("700".to_string()); - } - ParsedStyleFormat::Italic => { - style_css.font_style = Some("italic".to_string()); - } - ParsedStyleFormat::Underline => { - style_css.text_decoration = Some("underline".to_string()); - } - ParsedStyleFormat::Strikethrough => { - style_css.text_decoration = Some("line-through".to_string()); - } - } - } - - TextItem { - parsed_style, - text, - style: style_css, - } - }) - .collect(); - - TextParserResult { items, bg_color } -} diff --git a/src/svg/puretext/parsers/traits_parser.rs b/src/svg/puretext/parsers/traits_parser.rs deleted file mode 100644 index eb021e4..0000000 --- a/src/svg/puretext/parsers/traits_parser.rs +++ /dev/null @@ -1,153 +0,0 @@ -use serde_json::Value; -use std::collections::HashMap; - -use crate::{ - svg::puretext::{ - constants::{parse_string_to_array, ARRAY_INDEX_REG, ARRAY_REG}, - SimpleDOBOutput, TraitExt, - }, - types::{ParsedTrait, StandardDOBOutput}, -}; - -#[derive(Clone, Debug)] -pub struct TraitsParserResult { - pub traits: Vec, - pub index_var_register: HashMap, -} - -pub fn dob_output_parser(items: &[StandardDOBOutput]) -> TraitsParserResult { - // Build index variable register - let index_var_register = items - .iter() - .filter_map(|item| { - let first_trait = item.traits.first()?; - let string_value = first_trait.get_string_value()?; - - if let Some(captures) = ARRAY_INDEX_REG.captures(string_value) { - if let Some(index_match) = captures.get(1) { - if let Ok(int_index) = index_match.as_str().parse::() { - return Some((item.name.clone(), int_index)); - } - } - } - None - }) - .collect::>(); - - // Parse traits - let traits = items - .iter() - .filter_map(|item| { - let first_trait = item.traits.first()?; - - // Handle String traits - if let Some(string_value) = first_trait.get_string_value() { - let mut value = string_value.to_string(); - - // Handle array indexing - if let Some(captures) = ARRAY_REG.captures(&value) { - if let Some(var_name_match) = captures.get(1) { - if let Some(array_match) = captures.get(2) { - let var_name = var_name_match.as_str(); - let array = parse_string_to_array(array_match.as_str()); - - if let Some(&index) = index_var_register.get(var_name) { - let array_index = (index as usize) % array.len(); - value = array[array_index].clone(); - } - } - } - } - - return Some(SimpleDOBOutput { - name: item.name.clone(), - value: Value::String(value), - }); - } - - // Handle Number traits - if let Some(number_value) = first_trait.get_number_value() { - return Some(SimpleDOBOutput { - name: item.name.clone(), - value: Value::Number(serde_json::Number::from(number_value)), - }); - } - - // Handle Timestamp traits - if let Some(timestamp_value) = first_trait.get_timestamp_value() { - let mut timestamp = timestamp_value; - - // Convert 10-digit timestamp to milliseconds if needed - if timestamp.to_string().len() == 10 { - timestamp *= 1000; - } - - // Convert to ISO string format - let timestamp_ms = timestamp as i64; - let datetime = chrono::DateTime::from_timestamp_millis(timestamp_ms)?; - let iso_string = datetime.to_rfc3339(); - - return Some(SimpleDOBOutput { - name: item.name.clone(), - value: Value::String(iso_string), - }); - } - - // Handle SVG traits - if let Some(svg_value) = first_trait.get_svg_value() { - let resolved_svg = resolve_svg_traits(svg_value); - return Some(SimpleDOBOutput { - name: item.name.clone(), - value: Value::String(resolved_svg), - }); - } - - None - }) - .collect(); - - TraitsParserResult { - traits, - index_var_register, - } -} - -fn resolve_svg_traits(svg: &str) -> String { - // For now, just return the SVG as-is - // This could be expanded to handle SVG trait resolution - svg.to_string() -} - -impl TraitExt for ParsedTrait { - fn get_string_value(&self) -> Option<&str> { - if self.type_ == "String" { - self.value.as_str() - } else { - None - } - } - - fn get_number_value(&self) -> Option { - if self.type_ == "Number" { - self.value.as_u64() - } else { - None - } - } - - fn get_timestamp_value(&self) -> Option { - if self.type_ == "Timestamp" { - self.value.as_u64() - } else { - None - } - } - - fn get_svg_value(&self) -> Option<&str> { - if self.type_ == "SVG" { - self.value.as_str() - } else { - None - } - } -} diff --git a/src/svg/puretext/render.rs b/src/svg/puretext/render.rs deleted file mode 100644 index d0b409a..0000000 --- a/src/svg/puretext/render.rs +++ /dev/null @@ -1,353 +0,0 @@ -use crate::svg::{ - puretext::font::path::pathify_svg_texts, - puretext::parsers::{ParsedStyleAlignment, TextItem, TextParserResult}, - DEFAULT_SIZE, -}; - -#[derive(Debug, Clone)] -pub struct RenderProps { - pub items: Vec, - pub bg_color: String, -} - -impl From for RenderProps { - fn from(result: TextParserResult) -> Self { - Self { - items: result.items, - bg_color: result.bg_color, - } - } -} - -#[derive(Debug, Clone)] -pub struct RenderElement { - pub element_type: String, - pub props: ElementProps, -} - -#[derive(Debug, Clone)] -pub struct ElementProps { - pub children: Vec, - pub style: StyleProps, -} - -#[derive(Debug, Clone, Default)] -pub struct StyleProps { - pub justify_content: Option, - pub color: Option, - pub font_weight: Option, - pub font_style: Option, - pub text_decoration: Option, -} - -pub fn render_text_svg(props: RenderProps) -> String { - // Convert text items to render elements - let children = convert_items_to_elements(&props.items); - - // Generate SVG - generate_svg(&children, &props.bg_color) -} - -fn convert_items_to_elements(items: &[TextItem]) -> Vec { - let mut elements = Vec::new(); - - for item in items { - let justify_content = match item.parsed_style.alignment { - ParsedStyleAlignment::Left => "flex-start", - ParsedStyleAlignment::Center => "center", - ParsedStyleAlignment::Right => "flex-end", - }; - - let mut style = StyleProps { - justify_content: Some(justify_content.to_string()), - ..Default::default() - }; - - // Apply text styling - if let Some(color) = &item.style.color { - style.color = Some(color.clone()); - } - if let Some(font_weight) = &item.style.font_weight { - style.font_weight = Some(font_weight.clone()); - } - if let Some(font_style) = &item.style.font_style { - style.font_style = Some(font_style.clone()); - } - if let Some(text_decoration) = &item.style.text_decoration { - style.text_decoration = Some(text_decoration.clone()); - } - - let element = RenderElement { - element_type: "p".to_string(), - props: ElementProps { - children: vec![item.text.clone()], - style: style.clone(), - }, - }; - - // Handle break line logic - if item.parsed_style.break_line == 0 { - // No line break - just add the element - elements.push(element); - } else { - // Add the main element - elements.push(element); - - // Add additional line breaks - for _ in 0..item.parsed_style.break_line { - let break_element = RenderElement { - element_type: "p".to_string(), - props: ElementProps { - children: vec![], - style: Default::default(), - }, - }; - elements.push(break_element); - } - } - } - - elements -} - -fn generate_svg(elements: &[RenderElement], bg_color: &str) -> String { - let width = DEFAULT_SIZE; - let padding_x = 20; - let padding_y = 30; - let line_height = 28; - let font_size = 36; - - let mut svg_content = String::new(); - let mut current_y = padding_y; // Start after top padding - - // Calculate dynamic height based on content with minimum of 500 - let calculated_height = elements.len() * line_height + padding_y; - let height = std::cmp::max(calculated_height as u32, DEFAULT_SIZE); - - // Generate SVG content - for element in elements { - if element.element_type == "p" { - if !element.props.children.is_empty() { - let text = &element.props.children[0]; - if !text.is_empty() { - let text_anchor = match element.props.style.justify_content.as_deref() { - Some("center") => "middle", - Some("flex-end") => "end", - _ => "start", - }; - - let color = element.props.style.color.as_deref().unwrap_or("#ffffff"); - - // Determine font weight for Turret Road font family - let font_weight_to_use = - if let Some(font_weight) = &element.props.style.font_weight { - font_weight - } else { - "400" - }; - - let text_element = format!( - r#"{}"#, - padding_x, - current_y, - font_weight_to_use, - font_size, - color, - text_anchor, - escape_xml(text) - ); - - svg_content.push_str(&text_element); - } - } - current_y += line_height; - } - } - - // Create font definitions with only used weights - // let font_defs = create_font_definitions(&used_font_weights); - - // Create gradient definition if bg_color is a gradient - let (background_rect, gradient_defs) = if bg_color.starts_with("linear-gradient") { - create_gradient_background(bg_color, width, height) - } else { - // Use solid color - let rect = format!( - r#""#, - width, height, bg_color - ); - (rect, String::new()) - }; - - // Create the complete SVG - let svg_with_text = format!( - r#"{}{}{}"#, - width, height, gradient_defs, background_rect, svg_content - ); - - // Convert text elements to paths to avoid font loading issues - pathify_svg_texts(&svg_with_text) -} - -fn create_gradient_background(gradient_css: &str, width: u32, height: u32) -> (String, String) { - // Parse CSS linear-gradient format: linear-gradient(70deg, blue, pink, #f00) - let gradient_id = "background-gradient"; - - // Extract angle and colors from the gradient string - let (angle, colors) = parse_gradient_css(gradient_css); - - // Calculate gradient coordinates based on angle - let (x1, y1, x2, y2) = calculate_gradient_coordinates(angle); - - // Create gradient stops - let mut stops = String::new(); - let num_colors = colors.len(); - for (i, color) in colors.iter().enumerate() { - let offset = if num_colors == 1 { - "0%".to_string() - } else { - format!("{}%", (i * 100) / (num_colors - 1)) - }; - stops.push_str(&format!( - r#""#, - offset, color - )); - } - - // Create gradient definition - let gradient_defs = format!( - r#"{}"#, - gradient_id, x1, y1, x2, y2, stops - ); - - // Create background rect with gradient fill - let background_rect = format!( - r#""#, - width, height, gradient_id - ); - - (background_rect, gradient_defs) -} - -fn calculate_gradient_coordinates(angle: f64) -> (String, String, String, String) { - // For CSS linear-gradient, 0deg points up, 90deg points right - // We need to calculate the gradient line that goes through the rectangle at the given angle - - // Normalize angle to 0-360 range - let normalized_angle = angle % 360.0; - - // Convert CSS angle to mathematical angle (CSS: 0deg = up, Math: 0deg = right) - // CSS angles are measured clockwise from top, math angles counter-clockwise from right - let math_angle = (90.0 - normalized_angle).to_radians(); - - // Calculate the direction vector - let dx = math_angle.cos(); - let dy = -math_angle.sin(); // Negative because SVG y-axis is flipped - - // For a rectangle with width=100% and height=100%, we need to find - // the intersection points of the gradient line with the rectangle boundaries - // The gradient line passes through the center (50%, 50%) of the rectangle - - // Calculate the intersection points with the rectangle boundaries - // We'll use parametric line equations to find where the line intersects the rectangle - - let center_x = 0.5; // 50% - let center_y = 0.5; // 50% - - // Find the parameter t where the line intersects each boundary - let mut t_values = Vec::new(); - - // Intersection with left boundary (x = 0) - if dx.abs() > 1e-10 { - let t = -center_x / dx; - let y = center_y + t * dy; - if (0.0..=1.0).contains(&y) { - t_values.push((t, 0.0, y)); - } - } - - // Intersection with right boundary (x = 1) - if dx.abs() > 1e-10 { - let t = (1.0 - center_x) / dx; - let y = center_y + t * dy; - if (0.0..=1.0).contains(&y) { - t_values.push((t, 1.0, y)); - } - } - - // Intersection with top boundary (y = 0) - if dy.abs() > 1e-10 { - let t = -center_y / dy; - let x = center_x + t * dx; - if (0.0..=1.0).contains(&x) { - t_values.push((t, x, 0.0)); - } - } - - // Intersection with bottom boundary (y = 1) - if dy.abs() > 1e-10 { - let t = (1.0 - center_y) / dy; - let x = center_x + t * dx; - if (0.0..=1.0).contains(&x) { - t_values.push((t, x, 1.0)); - } - } - - // Sort by parameter t to get the two endpoints - t_values.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); - - if t_values.len() >= 2 { - let (_, x1, y1) = t_values[0]; - let (_, x2, y2) = t_values[t_values.len() - 1]; - - // Convert to percentage strings - // Use the endpoints as calculated to match CSS linear-gradient behavior - ( - format!("{:.1}%", x1 * 100.0), - format!("{:.1}%", y1 * 100.0), - format!("{:.1}%", x2 * 100.0), - format!("{:.1}%", y2 * 100.0), - ) - } else { - // Fallback for edge cases - ( - "0%".to_string(), - "0%".to_string(), - "100%".to_string(), - "100%".to_string(), - ) - } -} - -fn parse_gradient_css(gradient_css: &str) -> (f64, Vec) { - // Parse: linear-gradient(70deg, blue, pink, #f00) - let mut parts = gradient_css - .trim_start_matches("linear-gradient(") - .trim_end_matches(")") - .split(','); - - // Extract angle - let angle_part = parts.next().unwrap_or("0deg").trim(); - let angle = angle_part - .trim_end_matches("deg") - .parse::() - .unwrap_or(0.0); - - // Extract colors - let colors: Vec = parts.map(|s| s.trim().to_string()).collect(); - - (angle, colors) -} - -fn escape_xml(text: &str) -> String { - text.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - .replace("'", "'") -} - -pub fn render_text_parser_result_to_svg(text_parser_result: &TextParserResult) -> String { - let props = RenderProps::from(text_parser_result.clone()); - render_text_svg(props) -} diff --git a/src/tests/dob0/legacy_decoder.rs b/src/tests/dob0/legacy_decoder.rs index 220e35b..d7f70cc 100644 --- a/src/tests/dob0/legacy_decoder.rs +++ b/src/tests/dob0/legacy_decoder.rs @@ -1,13 +1,10 @@ use ckb_types::{h256, H256}; use crate::decoder::{helpers::decode_spore_content, DOBDecoder}; -use crate::svg::puretext::parsers::{dob_output_parser, render_text_params_parser}; -use crate::svg::puretext::render::render_text_parser_result_to_svg; -use crate::svg::DOBSvgExtractor; use crate::tests::{prepare_settings, SettingType}; use crate::types::{ ClusterDescriptionField, DOBClusterFormat, DOBClusterFormatV0, DOBDecoderFormat, - DecoderLocationType, StandardDOBOutput, + DecoderLocationType, }; use serde_json::{json, Value}; @@ -15,14 +12,6 @@ const EXPECTED_UNICORN_RENDER_RESULT: &str = "[{\"name\":\"wuxing_yinyang\",\"tr const EXPECTED_NERVAPE_RENDER_RESULT: &str = "[{\"name\":\"prev.type\",\"traits\":[{\"String\":\"text\"}]},{\"name\":\"prev.bg\",\"traits\":[{\"String\":\"btcfs://59e87ca177ef0fd457e87e9f93627660022cf519b531e1f4e3a6dda9e5e33827i0\"}]},{\"name\":\"prev.bgcolor\",\"traits\":[{\"String\":\"#CEBAF7\"}]},{\"name\":\"Background\",\"traits\":[{\"Number\":170}]},{\"name\":\"Suit\",\"traits\":[{\"Number\":236}]},{\"name\":\"Upper body\",\"traits\":[{\"Number\":53}]},{\"name\":\"Lower body\",\"traits\":[{\"Number\":189}]},{\"name\":\"Headwear\",\"traits\":[{\"Number\":175}]},{\"name\":\"Mask\",\"traits\":[{\"Number\":153}]},{\"name\":\"Eyewear\",\"traits\":[{\"Number\":126}]},{\"name\":\"Mouth\",\"traits\":[{\"Number\":14}]},{\"name\":\"Ears\",\"traits\":[{\"Number\":165}]},{\"name\":\"Tattoo\",\"traits\":[{\"Number\":231}]},{\"name\":\"Accessory\",\"traits\":[{\"Number\":78}]},{\"name\":\"Handheld\",\"traits\":[{\"Number\":240}]},{\"name\":\"Special\",\"traits\":[{\"Number\":70}]}]"; const NERVAPE_SPORE_ID: H256 = h256!("0x9dd9604d44d6640d1533c9f97f89438f17526e645f6c35aa08d8c7d844578580"); -const MAINNET_NERVAPE_SPORE_ID: H256 = - h256!("0xbbe57f0e7f7ca6e6c59007b28150e39c9c6f5c209493801cfc9ef125e0937ed4"); -const UNICORN_SPORE_ID: H256 = - h256!("0xe5bd5bbf82fec9107ba86fb65b3756915ca0d3a28d5e13a0aa82269b62a129ef"); -const MAINNET_WORLD3_SPORE_ID: H256 = - h256!("0xd1b01eda64c924ffe83a8d7d6511ceb56dcde9d52722e9d2df3f2db3c13f1fda"); -const TESTNET_UNICORN_PNG_SPORE_ID: H256 = - h256!("0xe6b003cdbb042eff3b56fdceee725b42f9397b9044b8323056f283161c828357"); fn generate_nervape_dob_ingredients(onchain_decoder: bool) -> (Value, ClusterDescriptionField) { let nervape_content = json!({ @@ -180,169 +169,3 @@ fn test_decode_multiple_spore_data() { assert_eq!(v, dna, "object type comparison failed"); }); } - -#[tokio::test] -async fn test_unicorn_dna_to_svg() { - let render_result = decode_unicorn_dna(false).await; - let parsed_render_result: Vec = - serde_json::from_str(&render_result).unwrap(); - let output_parser_result = dob_output_parser(&parsed_render_result); - println!("\noutput_parser_result: {output_parser_result:?}"); - - let text_parser_result = render_text_params_parser( - &output_parser_result.traits, - &output_parser_result.index_var_register, - None, - ); - println!("\ntext_parser_result: {text_parser_result:?}"); - - let svg = render_text_parser_result_to_svg(&text_parser_result); - println!("\nGenerated SVG:\n{}", svg); -} - -#[tokio::test] -async fn test_fetch_and_decode_mainnet_nervape_dna_to_svg() { - let settings = prepare_settings(SettingType::Mainnet, vec![]); - let svg_extractor = DOBSvgExtractor::new(&settings.image_fetcher_url); - let decoder = DOBDecoder::new(settings); - let (_, dna, dob_metadata) = decoder - .fetch_decode_ingredients(MAINNET_NERVAPE_SPORE_ID.into()) - .await - .expect("fetch"); - let render_result = decoder - .decode_dna(&dna, dob_metadata) - // array type - .await - .expect("decode"); - let svg_content = svg_extractor.extract_svg(render_result).await.unwrap(); - println!("svg_content: {svg_content}"); -} - -#[test] -fn test_manual_render_output_to_svg() { - let render_output_source = serde_json::json!([ - { - "name": "wuxing_yinyang", - "traits": [ - { "String": "3<_>" } - ] - }, - { - "name": "prev.bgcolor", - "traits": [ - { "String": "#(70deg, blue, pink, #f00)" } - ] - }, - { - "name": "prev<%v>", - "traits": [ - { "String": "(%wuxing_yinyang):['#FFFFFF', '#FFFFFF', '#FFFFFF', '#FFFFFF', '#FFFFFF', '#000000', '#000000', '#000000', '#000000', '#000000'])" } - ] - }, - { - "name": "Spirits", - "traits": [ - { "String": "(%wuxing_yinyang):['Metal, Golden Body', 'Wood, Blue Body', 'Water, White Body', 'Fire, Red Body', 'Earth, Colorful Body']" } - ] - }, - { - "name": "Yin Yang", - "traits": [ - { "String": "(%wuxing_yinyang):['Yin, Long hair', 'Yin, Long hair', 'Yin, Long hair', 'Yin, Long hair', 'Yin, Long hair', 'Yang, Short Hair', 'Yang, Short Hair', 'Yang, Short Hair', 'Yang, Short Hair', 'Yang, Short Hair']" } - ] - }, - { - "name": "Talents", - "traits": [ - { "String": "(%wuxing_yinyang):['Guard', 'Attack', 'Death', 'Revival', 'Forget', 'Summon', 'Prophet', 'Curse', 'Hermit', 'Crown']" } - ] - }, - { - "name": "Horn", - "traits": [ - { "String": "(%wuxing_yinyang):['Praetorian Horn', 'Warrior Horn', 'Hel Horn', 'Shaman Horn', 'Lethe Horn', 'Bard Horn', 'Sibyl Horn ', 'Necromancer Horn', 'Lao Tsu Horn', 'Caesar Horn']" } - ] - }, - { - "name": "Wings", - "traits": [ - { "String": "Golden Wings" } - ] - }, - { - "name": "Tails<%k: %v>", - "traits": [ - { "String": "Meteor Tails<#000*bi>" } - ] - }, - { - "name": "Horseshoes", - "traits": [ - { "String": "Dimond Horseshoes" } - ] - }, - { - "name": "Destiny Number", - "traits": [ - { "Number": 59616 } - ] - }, - { - "name": "Lucky Number", - "traits": [ - { "Number": 35 } - ] - } - ]); - let render_output: Vec = - serde_json::from_value(render_output_source).unwrap(); - - let output_parser_result = dob_output_parser(&render_output); - println!("\noutput_parser_result: {output_parser_result:?}"); - - let text_parser_result = render_text_params_parser( - &output_parser_result.traits, - &output_parser_result.index_var_register, - None, - ); - println!("\ntext_parser_result: {text_parser_result:?}"); - - let svg = render_text_parser_result_to_svg(&text_parser_result); - println!("\nGenerated SVG:\n{}", svg); -} - -async fn decode_svg(network: SettingType, spore_id: H256) -> String { - let settings = prepare_settings(network, vec![]); - let svg_extractor = DOBSvgExtractor::new(&settings.image_fetcher_url); - let decoder = DOBDecoder::new(settings); - let (_, dna, dob_metadata) = decoder - .fetch_decode_ingredients(spore_id.into()) - .await - .expect("fetch"); - let render_result = decoder - .decode_dna(&dna, dob_metadata) - .await - .expect("decode"); - let svg_content = svg_extractor.extract_svg(render_result).await.unwrap(); - svg_content -} - -#[tokio::test] -async fn test_decode_mainnet_unicorn_to_svg() { - let svg_content = decode_svg(SettingType::Mainnet, UNICORN_SPORE_ID).await; - println!("svg_content: {svg_content}"); -} - -#[tokio::test] -async fn test_decode_mainnet_world3_to_svg() { - let svg_content = decode_svg(SettingType::Mainnet, MAINNET_WORLD3_SPORE_ID).await; - std::fs::write("world3.svg", &svg_content).unwrap(); - println!("svg_content: {svg_content}"); -} - -#[tokio::test] -async fn test_decode_testnet_unicorn_png_to_svg() { - let svg_content = decode_svg(SettingType::Testnet, TESTNET_UNICORN_PNG_SPORE_ID).await; - std::fs::write("unicorn_png.svg", &svg_content).unwrap(); - println!("svg_content: {svg_content}"); -} diff --git a/src/tests/dob1/decoder.rs b/src/tests/dob1/decoder.rs index 55ce15d..2b9622e 100644 --- a/src/tests/dob1/decoder.rs +++ b/src/tests/dob1/decoder.rs @@ -3,7 +3,6 @@ use serde_json::{json, Value}; use crate::{ decoder::DOBDecoder, - svg::DOBSvgExtractor, tests::{prepare_settings, SettingType}, types::{ ClusterDescriptionField, DOBClusterFormat, DOBClusterFormatV0, DOBClusterFormatV1, @@ -68,15 +67,3 @@ async fn test_dob1_basic_decode() { let render_result = decoder.decode_dna(dna, dob_metadata).await.expect("decode"); println!("\nrender_result: {}", render_result); } - -#[tokio::test] -async fn test_mainnet_dob1_decode_to_svg() { - let settings = prepare_settings(SettingType::Mainnet, vec![]); - let svg_extractor = DOBSvgExtractor::new(&settings.image_fetcher_url); - let (content, dob_metadata) = generate_dob1_ingredients(); - let decoder = DOBDecoder::new(settings); - let dna = content.get("dna").unwrap().as_str().unwrap(); - let render_result = decoder.decode_dna(dna, dob_metadata).await.expect("decode"); - let svg_content = svg_extractor.extract_svg(render_result).await.unwrap(); - println!("svg_content: {svg_content}"); -} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index c8572c7..78b0eff 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -5,6 +5,7 @@ use crate::types::Settings; mod dob0; mod dob1; +#[allow(dead_code)] pub enum SettingType { Mainnet, Testnet, From c8513821db4eec8759d9dd0367e598cba208674b Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Sat, 20 Sep 2025 08:15:47 +0800 Subject: [PATCH 24/26] chore: use query to accept fsuri --- src/server/restful.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/server/restful.rs b/src/server/restful.rs index 339dbe7..c936fef 100644 --- a/src/server/restful.rs +++ b/src/server/restful.rs @@ -12,6 +12,7 @@ use crate::server::DecoderStandaloneServer; #[derive(Deserialize)] struct ExtractImageQuery { + uri: String, encode: Option, } @@ -22,10 +23,7 @@ impl DecoderStandaloneServer { Router::new() .route("/dob_decode/:spore_id", get(handle_dob_decode)) .route("/dob_batch_decode/:spore_ids", get(handle_dob_batch_decode)) - .route( - "/dob_extract_image/:fsproto/:uri", - get(handle_extract_image_from_fsuri), - ) + .route("/dob_extract_image", get(handle_extract_image_from_fsuri)) .route("/protocol_versions", get(handle_protocol_versions)) } } @@ -80,18 +78,17 @@ async fn handle_dob_batch_decode( /// Handle dob_extract_image_from_fsuri RESTful endpoint async fn handle_extract_image_from_fsuri( - Path((fsproto, uri)): Path<(String, String)>, Query(query): Query, State(server): State, ) -> impl IntoResponse { tracing::info!( - "RESTful API: extracting image from fsuri: {fsproto}://{uri} with encode: {:?}", + "RESTful API: extracting image from fsuri: {} with encode: {:?}", + query.uri, query.encode ); - let fsuri = format!("{}://{}", fsproto, uri); match server - .service_extract_image_from_fsuri(fsuri, query.encode) + .service_extract_image_from_fsuri(query.uri, query.encode) .await { Ok(result) => { From f1e138bb84f43a3eedc62d55665f8aab8b059a6a Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Sun, 28 Sep 2025 16:33:28 +0800 Subject: [PATCH 25/26] chore: enable CORS for RESTful api --- src/main.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6a8415c..0862097 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,14 +45,14 @@ async fn main() { // Create the decoder server instance let decoder_server = server::DecoderStandaloneServer::new(decoder, cache_expiration); - - // Start JSON-RPC server - tracing::info!("running JSON-RPC decoder server at {}", rpc_server_address); let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any); - let http_middleware = tower::ServiceBuilder::new().layer(cors); + + // Start JSON-RPC server + tracing::info!("running JSON-RPC decoder server at {}", rpc_server_address); + let http_middleware = tower::ServiceBuilder::new().layer(cors.clone()); let http_server = Server::builder() .set_http_middleware(http_middleware) .build(rpc_server_address.clone()) @@ -64,8 +64,10 @@ async fn main() { // Start RESTful API server let restful_handle = if let Some(restful_server_address) = restful_server_address { tracing::info!("running RESTful API server at {}", restful_server_address); - let app = - server::DecoderStandaloneServer::create_restful_routes().with_state(decoder_server); + + let app = server::DecoderStandaloneServer::create_restful_routes() + .with_state(decoder_server) + .layer(cors); let restful_listener = tokio::net::TcpListener::bind(&restful_server_address) .await From 68233449c88454cf4967eb41b011ec9e5955640c Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Wed, 3 Dec 2025 10:11:04 +0800 Subject: [PATCH 26/26] chore: accurate file elapsed time calculation --- src/decoder/helpers.rs | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/decoder/helpers.rs b/src/decoder/helpers.rs index f7a6857..1748523 100644 --- a/src/decoder/helpers.rs +++ b/src/decoder/helpers.rs @@ -1,4 +1,4 @@ -use std::{path::PathBuf, time::SystemTime}; +use std::path::PathBuf; use ckb_jsonrpc_types::Either; use ckb_sdk::{constants::TYPE_ID_CODE_HASH, rpc::ckb_indexer::Tx, traits::CellQueryOptions}; @@ -33,25 +33,22 @@ fn build_type_script_search_option(type_script: Script) -> CellQueryOptions { } fn file_older_than_minutes(file_path: &PathBuf, minutes: u64) -> bool { - match std::fs::metadata(file_path) { - Ok(metadata) => { - let Ok(mut duration) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) else { - return true; - }; - if let Ok(Ok(checkpoint)) = metadata - .modified() - .map(|time| time.duration_since(SystemTime::UNIX_EPOCH)) - { - duration = duration.saturating_sub(checkpoint); - } else if let Ok(Ok(checkpoint)) = metadata - .created() - .map(|time| time.duration_since(SystemTime::UNIX_EPOCH)) - { - duration = duration.saturating_sub(checkpoint); - } - duration.as_secs() / 60 >= minutes - } - Err(_) => true, + if minutes == 0 { + return true; + } + let metadata = match std::fs::metadata(file_path) { + Ok(m) => m, + Err(_) => return true, // File doesn't exist or we can't access it, so it's "old". + }; + + let file_time = match metadata.modified().or_else(|_| metadata.created()) { + Ok(time) => time, + Err(_) => return true, // Can't get a timestamp, assume it's old. + }; + + match file_time.elapsed() { + Ok(elapsed) => elapsed.as_secs() >= minutes * 60, + Err(_) => true, // System clock is earlier than file time, assume it's old to be safe. } }