1use crate::templates;
6use crate::toc::{Toc, TocElement};
7use crate::zip::Zip;
8use crate::ReferenceType;
9use crate::Result;
10use crate::{common, EpubContent};
11
12use core::fmt::Debug;
13use std::io;
14use std::io::Read;
15use std::path::Path;
16use std::str::FromStr;
17use upon::Engine;
18
19#[non_exhaustive]
23#[derive(Debug, Copy, Clone, PartialOrd, PartialEq, Eq)]
24pub enum EpubVersion {
25 V20,
27 V30,
29 V33,
31}
32
33pub trait MetadataRenderer: Send + Sync {
34 fn render_opf(&self, escape_html: bool) -> String;
35}
36
37impl Debug for dyn MetadataRenderer {
38 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
39 write!(f, "MetadataRenderer{{{}}}", self.render_opf(true))
40 }
41}
42
43#[derive(Debug)]
48pub struct MetadataOpfV3 {
49 pub property: String,
53
54 pub content: String,
56
57 pub dir: Option<String>,
61
62 pub id: Option<String>,
65
66 pub refines: Option<String>,
70
71 pub scheme: Option<String>,
75
76 pub xml_lang: Option<String>,
80}
81
82impl MetadataOpfV3 {
83 pub fn new(property: String, content: String) -> MetadataOpfV3 {
86 MetadataOpfV3{
87 property: property,
88 content: content,
89 dir: None,
90 id: None,
91 refines: None,
92 scheme: None,
93 xml_lang: None,
94 }
95 }
96
97 pub fn add_direction(&mut self, direction: String) -> &mut Self {
99 self.dir = Some(direction);
100 self
101 }
102
103 pub fn add_id(&mut self, id: String) -> &mut Self {
105 self.id = Some(id);
106 self
107 }
108
109 pub fn add_refines(&mut self, refines: String) -> &mut Self {
111 self.id = Some(refines);
112 self
113 }
114
115 pub fn add_scheme(&mut self, scheme: String) -> &mut Self {
117 self.scheme = Some(scheme);
118 self
119 }
120
121 pub fn add_xml_lang(&mut self, xml_lang: String) -> &mut Self {
123 self.xml_lang = Some(xml_lang);
124 self
125 }
126}
127
128impl MetadataRenderer for MetadataOpfV3 {
129 fn render_opf(&self, escape_html: bool) -> String {
131 let mut meta_tag = String::from("<meta ");
132
133 if let Some(dir) = &self.dir {
134 meta_tag.push_str(&format!(
135 "dir=\"{}\" ", common::encode_html(dir, escape_html),
136 ));
137 }
138
139 if let Some(id) = &self.id {
140 meta_tag.push_str(&format!(
141 "id=\"{}\" ", common::encode_html(id, escape_html),
142 ));
143 }
144
145 if let Some(refines) = &self.refines {
146 meta_tag.push_str(&format!(
147 "refines=\"{}\" ", common::encode_html(refines, escape_html)
148 ));
149 }
150
151 if let Some(scheme) = &self.scheme {
152 meta_tag.push_str(&format!(
153 "scheme=\"{}\" ", common::encode_html(scheme, escape_html),
154 ));
155 }
156
157 if let Some(xml_lang) = &self.xml_lang {
158 meta_tag.push_str(&format!(
159 "xml:lang=\"{}\" ", common::encode_html(xml_lang, escape_html)
160 ));
161 }
162
163 meta_tag.push_str(&format!(
164 "property=\"{}\">{}</meta>",
165 common::encode_html(&self.property, escape_html),
166 &self.content,
167 ));
168
169 meta_tag
170 }
171}
172
173#[derive(Debug)]
178pub struct MetadataOpf {
179 pub name: String,
181 pub content: String
183}
184
185impl MetadataOpf {
186 pub fn new(&self, meta_name: String, meta_content: String) -> Self {
190 Self { name: meta_name, content: meta_content }
191 }
192}
193
194impl MetadataRenderer for MetadataOpf {
195 fn render_opf(&self, escape_html: bool) -> String {
196 format!(
197 "<meta name=\"{}\" content=\"{}\"/>",
198 common::encode_html(&self.name, escape_html),
199 common::encode_html(&self.content, escape_html),
200 )
201 }
202}
203
204#[derive(Debug, Copy, Clone, Default)]
207pub enum PageDirection {
208 #[default]
210 Ltr,
211 Rtl,
213}
214
215impl ToString for PageDirection {
216 fn to_string(&self) -> String {
217 match &self {
218 PageDirection::Rtl => "rtl".into(),
219 PageDirection::Ltr => "ltr".into(),
220 }
221 }
222}
223
224impl FromStr for PageDirection {
225 type Err = crate::Error;
226
227 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
228 let s = s.to_lowercase();
229 match s.as_ref() {
230 "rtl" => Ok(PageDirection::Rtl),
231 "ltr" => Ok(PageDirection::Ltr),
232 _ => Err(crate::Error::PageDirectionError(s)),
233 }
234 }
235}
236
237#[derive(Debug)]
239pub struct Metadata {
240 pub title: String,
241 pub author: Vec<String>,
242 pub lang: String,
243 pub direction: PageDirection,
244 pub generator: String,
245 pub toc_name: String,
246 pub description: Vec<String>,
247 pub subject: Vec<String>,
248 pub license: Option<String>,
249 pub date_published: Option<chrono::DateTime<chrono::Utc>>,
250 pub date_modified: Option<chrono::DateTime<chrono::Utc>>,
251 pub uuid: Option<uuid::Uuid>,
252}
253
254impl Default for Metadata {
255 fn default() -> Self {
256 Self {
257 title: String::new(),
258 author: vec![],
259 lang: String::from("en"),
260 direction: PageDirection::default(),
261 generator: String::from("Rust EPUB library"),
262 toc_name: String::from("Table Of Contents"),
263 description: vec![],
264 subject: vec![],
265 license: None,
266 date_published: None,
267 date_modified: None,
268 uuid: None,
269 }
270 }
271}
272
273#[derive(Debug)]
275struct Content {
276 pub file: String,
277 pub mime: String,
278 pub itemref: bool,
279 pub cover: bool,
280 pub reftype: Option<ReferenceType>,
281 pub title: String,
282}
283
284impl Content {
285 pub fn new<S1, S2>(file: S1, mime: S2) -> Content
287 where
288 S1: Into<String>,
289 S2: Into<String>,
290 {
291 Content {
292 file: file.into(),
293 mime: mime.into(),
294 itemref: false,
295 cover: false,
296 reftype: None,
297 title: String::new(),
298 }
299 }
300}
301
302#[derive(Debug)]
320pub struct EpubBuilder<Z: Zip> {
321 version: EpubVersion,
322 direction: PageDirection,
323 zip: Z,
324 files: Vec<Content>,
325 metadata: Metadata,
326 toc: Toc,
327 stylesheet: bool,
328 inline_toc: bool,
329 escape_html: bool,
330 meta_opf: Vec<Box<dyn MetadataRenderer>>,
331}
332
333impl<Z: Zip> EpubBuilder<Z> {
334 pub fn new(zip: Z) -> Result<EpubBuilder<Z>> {
336 let mut epub = EpubBuilder {
337 version: EpubVersion::V20,
338 direction: PageDirection::Ltr,
339 zip,
340 files: vec![],
341 metadata: Metadata::default(),
342 toc: Toc::new(),
343 stylesheet: false,
344 inline_toc: false,
345 escape_html: true,
346 meta_opf: vec![],
347 };
348
349 epub.zip
350 .write_file("META-INF/container.xml", templates::CONTAINER)?;
351 epub.zip.write_file(
352 "META-INF/com.apple.ibooks.display-options.xml",
353 templates::IBOOKS,
354 )?;
355
356 Ok(epub)
357 }
358
359 pub fn epub_version(&mut self, version: EpubVersion) -> &mut Self {
367 self.version = version;
368 self
369 }
370
371 pub fn epub_direction(&mut self, direction: PageDirection) -> &mut Self {
378 self.direction = direction;
379 self
380 }
381
382 pub fn add_metadata_opf(&mut self, item: Box<dyn MetadataRenderer>) -> &mut Self {
409 self.meta_opf.push(item);
410 self
411 }
412
413 pub fn metadata<S1, S2>(&mut self, key: S1, value: S2) -> Result<&mut Self>
432 where
433 S1: AsRef<str>,
434 S2: Into<String>,
435 {
436 match key.as_ref() {
437 "author" => {
438 let value = value.into();
439 if value.is_empty() {
440 self.metadata.author = vec![];
441 } else {
442 self.metadata.author.push(value);
443 }
444 }
445 "title" => self.metadata.title = value.into(),
446 "lang" => self.metadata.lang = value.into(),
447 "direction" => self.metadata.direction = PageDirection::from_str(&value.into())?,
448 "generator" => self.metadata.generator = value.into(),
449 "description" => {
450 let value = value.into();
451 if value.is_empty() {
452 self.metadata.description = vec![];
453 } else {
454 self.metadata.description.push(value);
455 }
456 }
457 "subject" => {
458 let value = value.into();
459 if value.is_empty() {
460 self.metadata.subject = vec![];
461 } else {
462 self.metadata.subject.push(value);
463 }
464 }
465 "license" => self.metadata.license = Some(value.into()),
466 "toc_name" => self.metadata.toc_name = value.into(),
467 s => Err(crate::Error::InvalidMetadataError(s.to_string()))?,
468 }
469 Ok(self)
470 }
471
472 pub fn set_authors(&mut self, value: Vec<String>) {
474 self.metadata.author = value;
475 }
476
477 pub fn add_author<S: Into<String>>(&mut self, value: S) {
479 self.metadata.author.push(value.into());
480 }
481
482 pub fn clear_authors<S: Into<String>>(&mut self) {
484 self.metadata.author.clear()
485 }
486
487 pub fn set_title<S: Into<String>>(&mut self, value: S) {
489 self.metadata.title = value.into();
490 }
491
492 pub fn escape_html(&mut self, val: bool) {
498 self.escape_html = val;
499 }
500
501 pub fn set_lang<S: Into<String>>(&mut self, value: S) {
506 self.metadata.lang = value.into();
507 }
508
509 pub fn set_generator<S: Into<String>>(&mut self, value: S) {
511 self.metadata.generator = value.into();
512 }
513
514 pub fn set_toc_name<S: Into<String>>(&mut self, value: S) {
516 self.metadata.toc_name = value.into();
517 }
518
519 pub fn set_description(&mut self, value: Vec<String>) {
521 self.metadata.description = value;
522 }
523
524 pub fn add_description<S: Into<String>>(&mut self, value: S) {
526 self.metadata.description.push(value.into());
527 }
528
529 pub fn clear_description(&mut self) {
531 self.metadata.description.clear();
532 }
533
534 pub fn set_subjects(&mut self, value: Vec<String>) {
536 self.metadata.subject = value;
537 }
538
539 pub fn add_subject<S: Into<String>>(&mut self, value: S) {
541 self.metadata.subject.push(value.into());
542 }
543
544 pub fn clear_subjects(&mut self) {
546 self.metadata.subject.clear();
547 }
548
549 pub fn set_license<S: Into<String>>(&mut self, value: S) {
551 self.metadata.license = Some(value.into());
552 }
553
554 pub fn set_publication_date(&mut self, date_published: chrono::DateTime<chrono::Utc>) {
556 self.metadata.date_published = Some(date_published);
557 }
558 pub fn set_modified_date(&mut self, date_modified: chrono::DateTime<chrono::Utc>) {
563 self.metadata.date_modified = Some(date_modified);
564 }
565 pub fn set_uuid(&mut self, uuid: uuid::Uuid) {
569 self.metadata.uuid = Some(uuid);
570 }
571
572 pub fn stylesheet<R: Read>(&mut self, content: R) -> Result<&mut Self> {
578 self.add_resource("stylesheet.css", content, "text/css")?;
579 self.stylesheet = true;
580 Ok(self)
581 }
582
583 pub fn inline_toc(&mut self) -> &mut Self {
592 self.inline_toc = true;
593 self.toc.add(TocElement::new(
594 "toc.xhtml",
595 self.metadata.toc_name.as_str(),
596 ));
597 let mut file = Content::new("toc.xhtml", "application/xhtml+xml");
598 file.reftype = Some(ReferenceType::Toc);
599 file.title = self.metadata.toc_name.clone();
600 file.itemref = true;
601 self.files.push(file);
602 self
603 }
604
605 pub fn add_resource<R, P, S>(&mut self, path: P, content: R, mime_type: S) -> Result<&mut Self>
621 where
622 R: Read,
623 P: AsRef<Path>,
624 S: Into<String>,
625 {
626 self.zip
627 .write_file(Path::new("OEBPS").join(path.as_ref()), content)?;
628 log::debug!("Add resource: {:?}", path.as_ref().display());
629 self.files.push(Content::new(
630 format!("{}", path.as_ref().display()),
631 mime_type,
632 ));
633 Ok(self)
634 }
635
636 pub fn add_cover_image<R, P, S>(
642 &mut self,
643 path: P,
644 content: R,
645 mime_type: S,
646 ) -> Result<&mut Self>
647 where
648 R: Read,
649 P: AsRef<Path>,
650 S: Into<String>,
651 {
652 self.zip
653 .write_file(Path::new("OEBPS").join(path.as_ref()), content)?;
654 let mut file = Content::new(format!("{}", path.as_ref().display()), mime_type);
655 file.cover = true;
656 self.files.push(file);
657 Ok(self)
658 }
659
660 pub fn add_content<R: Read>(&mut self, content: EpubContent<R>) -> Result<&mut Self> {
701 self.zip.write_file(
702 Path::new("OEBPS").join(content.toc.url.as_str()),
703 content.content,
704 )?;
705 let mut file = Content::new(content.toc.url.as_str(), "application/xhtml+xml");
706 file.itemref = true;
707 file.reftype = content.reftype;
708 if file.reftype.is_some() {
709 file.title = content.toc.title.clone();
710 }
711 self.files.push(file);
712 if !content.toc.title.is_empty() {
713 self.toc.add(content.toc);
714 }
715 Ok(self)
716 }
717
718 pub fn generate<W: io::Write>(mut self, to: W) -> Result<()> {
730 if !self.stylesheet {
732 self.stylesheet(b"".as_ref())?;
733 }
734 let bytes = self.render_opf()?;
736 self.zip.write_file("OEBPS/content.opf", &*bytes)?;
737 let bytes = self.render_toc()?;
739 self.zip.write_file("OEBPS/toc.ncx", &*bytes)?;
740 let bytes = self.render_nav(true)?;
742 self.zip.write_file("OEBPS/nav.xhtml", &*bytes)?;
743 if self.inline_toc {
745 let bytes = self.render_nav(false)?;
746 self.zip.write_file("OEBPS/toc.xhtml", &*bytes)?;
747 }
748
749 self.zip.generate(to)?;
750 Ok(())
751 }
752
753 fn render_opf(&mut self) -> Result<Vec<u8>> {
755 log::debug!("render_opf...");
756 let mut optional: Vec<String> = Vec::new();
757 for desc in &self.metadata.description {
758 optional.push(format!(
759 "<dc:description>{}</dc:description>",
760 common::encode_html(desc, self.escape_html),
761 ));
762 }
763 for subject in &self.metadata.subject {
764 optional.push(format!(
765 "<dc:subject>{}</dc:subject>",
766 common::encode_html(subject, self.escape_html),
767 ));
768 }
769 if let Some(ref rights) = self.metadata.license {
770 optional.push(format!(
771 "<dc:rights>{}</dc:rights>",
772 common::encode_html(rights, self.escape_html),
773 ));
774 }
775
776 for meta in &self.meta_opf {
777 optional.push(meta.render_opf(self.escape_html))
778 }
779
780 let date_modified = self
781 .metadata
782 .date_modified
783 .unwrap_or_else(chrono::Utc::now)
784 .format("%Y-%m-%dT%H:%M:%SZ");
785 let date_published = self
786 .metadata
787 .date_published
788 .map(|date| date.format("%Y-%m-%dT%H:%M:%SZ"));
789 let uuid = uuid::fmt::Urn::from_uuid(self.metadata.uuid.unwrap_or_else(uuid::Uuid::new_v4))
790 .to_string();
791
792 let mut items: Vec<String> = Vec::new();
793 let mut itemrefs: Vec<String> = Vec::new();
794 let mut guide: Vec<String> = Vec::new();
795
796 for content in &self.files {
797 let id = if content.cover {
798 String::from("cover-image")
799 } else {
800 to_id(&content.file)
801 };
802 let properties = match (self.version, content.cover) {
803 (EpubVersion::V30, true) => "properties=\"cover-image\" ",
804 (EpubVersion::V33, true) => "properties=\"cover-image\" ",
805 _ => "",
806 };
807 if content.cover {
808 optional.push("<meta name=\"cover\" content=\"cover-image\"/>".to_string());
809 }
810 log::debug!("id={:?}, mime={:?}", id, content.mime);
811 items.push(format!(
812 "<item media-type=\"{mime}\" {properties}\
813 id=\"{id}\" href=\"{href}\"/>",
814 properties = properties, mime = html_escape::encode_double_quoted_attribute(&content.mime),
816 id = html_escape::encode_double_quoted_attribute(&id),
817 href =
819 html_escape::encode_double_quoted_attribute(&content.file.replace('\\', "/")),
820 ));
821 if content.itemref {
822 itemrefs.push(format!(
823 "<itemref idref=\"{id}\"/>",
824 id = html_escape::encode_double_quoted_attribute(&id),
825 ));
826 }
827 if let Some(reftype) = content.reftype {
828 use crate::ReferenceType::*;
829 let reftype = match reftype {
830 Cover => "cover",
831 TitlePage => "title-page",
832 Toc => "toc",
833 Index => "index",
834 Glossary => "glossary",
835 Acknowledgements => "acknowledgements",
836 Bibliography => "bibliography",
837 Colophon => "colophon",
838 Copyright => "copyright",
839 Dedication => "dedication",
840 Epigraph => "epigraph",
841 Foreword => "foreword",
842 Loi => "loi",
843 Lot => "lot",
844 Notes => "notes",
845 Preface => "preface",
846 Text => "text",
847 };
848 log::debug!("content = {:?}", &content);
849 guide.push(format!(
850 "<reference type=\"{reftype}\" title=\"{title}\" href=\"{href}\"/>",
851 reftype = html_escape::encode_double_quoted_attribute(&reftype),
852 title = html_escape::encode_double_quoted_attribute(&content.title),
853 href = html_escape::encode_double_quoted_attribute(&content.file),
854 ));
855 }
856 }
857
858 let data = {
859 let mut authors: Vec<_> = vec![];
860 for (i, author) in self.metadata.author.iter().enumerate() {
861 let author = upon::value! {
862 id_attr: html_escape::encode_double_quoted_attribute(&i.to_string()),
863 name: common::encode_html(author, self.escape_html)
864 };
865 authors.push(author);
866 }
867 upon::value! {
868 author: authors,
869 lang: html_escape::encode_text(&self.metadata.lang),
870 direction: self.metadata.direction.to_string(),
871 title: common::encode_html(&self.metadata.title, self.escape_html),
872 generator_attr: html_escape::encode_double_quoted_attribute(&self.metadata.generator),
873 toc_name: common::encode_html(&self.metadata.toc_name, self.escape_html),
874 toc_name_attr: html_escape::encode_double_quoted_attribute(&self.metadata.toc_name),
875 optional: common::indent(optional.join("\n"), 2),
876 items: common::indent(items.join("\n"), 2), itemrefs: common::indent(itemrefs.join("\n"), 2), date_modified: html_escape::encode_text(&date_modified.to_string()),
879 uuid: html_escape::encode_text(&uuid),
880 guide: common::indent(guide.join("\n"), 2), date_published: if let Some(date) = date_published { date.to_string() } else { String::new() },
882 }
883 };
884
885 let mut res: Vec<u8> = vec![];
886 match self.version {
887 EpubVersion::V20 => templates::v2::CONTENT_OPF.render(&Engine::new(), &data).to_writer(&mut res),
888 EpubVersion::V30 => templates::v3::CONTENT_OPF.render(&Engine::new(), &data).to_writer(&mut res),
889 EpubVersion::V33 => templates::v3::CONTENT_OPF.render(&Engine::new(), &data).to_writer(&mut res),
890 }
891 .map_err(|e| crate::Error::TemplateError {
892 msg: "could not render template for content.opf".to_string(),
893 cause: e.into(),
894 })?;
895 Ok(res)
898 }
899
900 fn render_toc(&mut self) -> Result<Vec<u8>> {
902 let mut nav_points = String::new();
903
904 nav_points.push_str(&self.toc.render_epub(self.escape_html));
905
906 let data = upon::value! {
907 toc_name: common::encode_html(&self.metadata.toc_name, self.escape_html),
908 nav_points: nav_points
909 };
910 let mut res: Vec<u8> = vec![];
911 templates::TOC_NCX
912 .render(&Engine::new(), &data)
913 .to_writer(&mut res)
914 .map_err(|e| crate::Error::TemplateError {
915 msg: "error rendering toc.ncx template".to_string(),
916 cause: e.into(),
917 })?;
918 Ok(res)
919 }
920
921 fn render_nav(&mut self, numbered: bool) -> Result<Vec<u8>> {
923 let content = self.toc.render(numbered, self.escape_html);
924 let mut landmarks: Vec<String> = Vec::new();
925 if self.version > EpubVersion::V20 {
926 for file in &self.files {
927 if let Some(ref reftype) = file.reftype {
928 use ReferenceType::*;
929 let reftype = match *reftype {
930 Cover => "cover",
931 Text => "bodymatter",
932 Toc => "toc",
933 Bibliography => "bibliography",
934 Epigraph => "epigraph",
935 Foreword => "foreword",
936 Preface => "preface",
937 Notes => "endnotes",
938 Loi => "loi",
939 Lot => "lot",
940 Colophon => "colophon",
941 TitlePage => "titlepage",
942 Index => "index",
943 Glossary => "glossary",
944 Copyright => "copyright-page",
945 Acknowledgements => "acknowledgements",
946 Dedication => "dedication",
947 };
948 if !file.title.is_empty() {
949 landmarks.push(format!(
950 "<li><a epub:type=\"{reftype}\" href=\"{href}\">\
951 {title}</a></li>",
952 reftype = html_escape::encode_double_quoted_attribute(&reftype),
953 href = html_escape::encode_double_quoted_attribute(&file.file),
954 title = common::encode_html(&file.title, self.escape_html),
955 ));
956 }
957 }
958 }
959 }
960
961 let data = upon::value! {
962 content: content, toc_name: common::encode_html(&self.metadata.toc_name, self.escape_html),
964 generator_attr: html_escape::encode_double_quoted_attribute(&self.metadata.generator),
965 landmarks: if !landmarks.is_empty() {
966 common::indent(
967 format!(
968 "<ol>\n{}\n</ol>",
969 common::indent(landmarks.join("\n"), 1), ),
971 2,
972 )
973 } else {
974 String::new()
975 },
976 };
977
978 let mut res: Vec<u8> = vec![];
979 match self.version {
980 EpubVersion::V20 => templates::v2::NAV_XHTML.render(&Engine::new(), &data).to_writer(&mut res),
981 EpubVersion::V30 => templates::v3::NAV_XHTML.render(&Engine::new(), &data).to_writer(&mut res),
982 EpubVersion::V33 => templates::v3::NAV_XHTML.render(&Engine::new(), &data).to_writer(&mut res),
983 }
984 .map_err(|e| crate::Error::TemplateError {
985 msg: "error rendering nav.xhtml template".to_string(),
986 cause: e.into(),
987 })?;
988 Ok(res)
989 }
990}
991
992fn is_id_char(c: char) -> bool {
996 c.is_ascii_uppercase()
997 || c == '_'
998 || c.is_ascii_lowercase()
999 || ('\u{C0}'..='\u{D6}').contains(&c)
1000 || ('\u{D8}'..='\u{F6}').contains(&c)
1001 || ('\u{F8}'..='\u{2FF}').contains(&c)
1002 || ('\u{370}'..='\u{37D}').contains(&c)
1003 || ('\u{37F}'..='\u{1FFF}').contains(&c)
1004 || ('\u{200C}'..='\u{200D}').contains(&c)
1005 || ('\u{2070}'..='\u{218F}').contains(&c)
1006 || ('\u{2C00}'..='\u{2FEF}').contains(&c)
1007 || ('\u{3001}'..='\u{D7FF}').contains(&c)
1008 || ('\u{F900}'..='\u{FDCF}').contains(&c)
1009 || ('\u{FDF0}'..='\u{FFFD}').contains(&c)
1010 || ('\u{10000}'..='\u{EFFFF}').contains(&c)
1011 || c == '-'
1012 || c == '.'
1013 || c.is_ascii_digit()
1014 || c == '\u{B7}'
1015 || ('\u{0300}'..='\u{036F}').contains(&c)
1016 || ('\u{203F}'..='\u{2040}').contains(&c)
1017}
1018
1019fn to_id(s: &str) -> String {
1021 "id_".to_string() + &s.replace(|c: char| !is_id_char(c), "_")
1022}