diff --git a/Cargo.toml b/Cargo.toml index 4728f4f7..4e9bd303 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ strum = { version = "0.26", features = ["derive"], optional = true } [dev-dependencies] heck = "0.5" serde_json = "1" +regex = "1" [[bin]] name = "hayagriva" diff --git a/src/csl/mod.rs b/src/csl/mod.rs index 8ac6a330..2d587f97 100644 --- a/src/csl/mod.rs +++ b/src/csl/mod.rs @@ -15,7 +15,7 @@ use citationberg::{ taxonomy as csl_taxonomy, Affixes, BaseLanguage, Citation, CitationFormat, Collapse, CslMacro, Display, GrammarGender, IndependentStyle, InheritableNameOptions, Layout, LayoutRenderingElement, Locale, LocaleCode, Names, SecondFieldAlign, StyleCategory, - StyleClass, TermForm, ToFormatting, + StyleClass, TermForm, ToAffixes, ToFormatting, }; use citationberg::{DateForm, LongShortForm, OrdinalLookup, TextCase}; use indexmap::IndexSet; @@ -1386,7 +1386,14 @@ impl<'a> StyleContext<'a> { let mut ctx = self.ctx(entry, props, locale, term_locale, true); ctx.writing .push_name_options(&self.csl.bibliography.as_ref()?.name_options); - self.csl.bibliography.as_ref()?.layout.render(&mut ctx); + + let layout = &self.csl.bibliography.as_ref()?.layout; + let affixes = layout.to_affixes(); + + let affix_loc = ctx.apply_prefix(&affixes); + layout.render(&mut ctx); + ctx.apply_suffix(&affixes, affix_loc); + Some(ctx) } diff --git a/tests/citeproc-pass.txt b/tests/citeproc-pass.txt index 3619e671..43469317 100644 --- a/tests/citeproc-pass.txt +++ b/tests/citeproc-pass.txt @@ -8,14 +8,25 @@ affix_InterveningEmpty affix_TextNodeWithMacro bugreports_Abnt bugreports_ArabicLocale +bugreports_AsaSpacing bugreports_AuthorYear bugreports_BadCitationUpdate bugreports_ChineseCharactersFamilyOnlyPluralLabel bugreports_ContextualPluralWithMainItemFields +bugreports_DisambiguationAddNamesBibliography bugreports_EmptyIfMatchNoneFail +bugreports_MatchedAuthorAndDate +bugreports_NoEventInNestedMacroWithOldProcessor bugreports_SectionAndLocator +bugreports_SimpleBib bugreports_SingletonIfMatchNoneFail +bugreports_SortSecondaryKeyBibliography +bugreports_StyleError001 bugreports_TitleCase +bugreports_UndefinedInName2 +bugreports_UndefinedNotString +bugreports_UndefinedStr +bugreports_UnisaHarvardInitialization bugreports_YearSuffixLingers bugreports_disambiguate bugreports_effingBug @@ -26,6 +37,7 @@ collapse_AuthorCollapseNoDate collapse_CitationNumberRangesMixed collapse_CitationNumberRangesMixed2 collapse_CitationNumberRangesMixed3 +collapse_CitationNumberRangesOneOnly collapse_NumericDuplicate collapse_NumericDuplicate2 collapse_TrailingDelimiter @@ -40,18 +52,21 @@ condition_NameAndTextVars condition_NumberIsNumeric condition_NumeralIsNumeric condition_NumeralWithTextIsNumeric +condition_RefTypeBranching condition_SingletonIfMatchNone condition_TextIsNotNumeric condition_VariableAll condition_VariableAny condition_VariableNone date_Accessed +date_AccessedCrash date_DateAD date_DateNoDateWithTest date_DisappearingBug date_EmptyStrings date_IgnoreNonexistentSort date_January +date_KeyVariable date_LiteralFailGracefullyIfNoValue date_LocalizedDateFormats-af-ZA date_LocalizedDateFormats-ar-AR @@ -121,21 +136,29 @@ disambiguate_DifferentSpacingInInitials disambiguate_DisambiguateTrueAndYearSuffixOne disambiguate_FailWithYearSuffix disambiguate_FamilyNameOnly +disambiguate_HonorFullnameInBibliography +disambiguate_ImplicitYearSuffixOnceOnly disambiguate_LastOnlyFailWithByCite disambiguate_NoTextElementUsesYearSuffixVariable disambiguate_PrimaryNameWithNonDroppingParticle +disambiguate_ThreeNoAuthorNoTitleEntries disambiguate_WithOriginalYear disambiguate_YearCollapseWithInstitution disambiguate_YearSuffixMacroSameYearExplicit disambiguate_YearSuffixMacroSameYearImplicit disambiguate_YearSuffixWithEtAlSubsequent +display_DisplayBlock +etal_CitationAndBibliographyDecorationsInBibliography flipflop_OrphanQuote form_TitleShort form_TitleShortNoLong form_TitleTestNoLongFalse +fullstyles_ABdNT fullstyles_APA +fullstyles_ChicagoNoteWithBibliographyWithPublisher group_ShortOutputOnly group_SuppressValueWithEmptySubgroup +group_SuppressWithEmptyNestedDateNode integration_CitationSort integration_CitationSortTwice label_CompactNamesAfterFullNames @@ -193,9 +216,12 @@ name_CeltsAndToffsWithHyphens name_CiteGroupDelimiterWithYearCollapse name_CollapseRoleLabels name_Delimiter +name_EditorTranslatorBoth name_EditorTranslatorSameEmptyTerm name_EditorTranslatorSameWithTerm +name_EditorTranslatorWithTranslatorOnlyBib name_EtAlKanji +name_EtAlUseLast name_FirstInitialFullForm name_FormattingOfParticles name_GreekSimple @@ -207,6 +233,7 @@ name_InstitutionDecoration name_LabelAfterPlural name_LabelAfterPluralDecorations name_LabelFormatBug +name_LiteralWithComma name_MultipleLiteral name_NoNameNode name_NonDroppingParticleDefault @@ -231,48 +258,98 @@ name_WesternTwoAuthors name_WithNonBreakingSpace name_namepartAffixesNameAsSortOrder name_namepartAffixesNameAsSortOrderDemoteNonDroppingParticle +nameattr_AndOnBibliographyInBibliography nameattr_AndOnBibliographyInCitation +nameattr_AndOnCitationInBibliography nameattr_AndOnCitationInCitation +nameattr_AndOnNamesInBibliography nameattr_AndOnNamesInCitation +nameattr_AndOnStyleInBibliography +nameattr_AndOnStyleInCitation +nameattr_DelimiterPrecedesEtAlOnBibliographyInBibliography nameattr_DelimiterPrecedesEtAlOnBibliographyInCitation +nameattr_DelimiterPrecedesEtAlOnCitationInBibliography nameattr_DelimiterPrecedesEtAlOnCitationInCitation +nameattr_DelimiterPrecedesEtAlOnNamesInBibliography nameattr_DelimiterPrecedesEtAlOnNamesInCitation +nameattr_DelimiterPrecedesEtAlOnStyleInBibliography nameattr_DelimiterPrecedesEtAlOnStyleInCitation +nameattr_DelimiterPrecedesLastOnBibliographyInBibliography nameattr_DelimiterPrecedesLastOnBibliographyInCitation +nameattr_DelimiterPrecedesLastOnCitationInBibliography nameattr_DelimiterPrecedesLastOnCitationInCitation +nameattr_DelimiterPrecedesLastOnNamesInBibliography nameattr_DelimiterPrecedesLastOnNamesInCitation +nameattr_DelimiterPrecedesLastOnStyleInBibliography nameattr_DelimiterPrecedesLastOnStyleInCitation +nameattr_EtAlMinOnBibliographyInBibliography nameattr_EtAlMinOnBibliographyInCitation +nameattr_EtAlMinOnCitationInBibliography nameattr_EtAlMinOnCitationInCitation +nameattr_EtAlMinOnNamesInBibliography nameattr_EtAlMinOnNamesInCitation +nameattr_EtAlMinOnStyleInBibliography nameattr_EtAlMinOnStyleInCitation +nameattr_EtAlSubsequentMinOnBibliographyInBibliography nameattr_EtAlSubsequentMinOnBibliographyInCitation +nameattr_EtAlSubsequentMinOnCitationInBibliography +nameattr_EtAlSubsequentMinOnNamesInBibliography +nameattr_EtAlSubsequentMinOnStyleInBibliography +nameattr_EtAlSubsequentUseFirstOnBibliographyInBibliography nameattr_EtAlSubsequentUseFirstOnBibliographyInCitation +nameattr_EtAlSubsequentUseFirstOnCitationInBibliography +nameattr_EtAlSubsequentUseFirstOnStyleInBibliography +nameattr_EtAlUseFirstOnBibliographyInBibliography nameattr_EtAlUseFirstOnBibliographyInCitation +nameattr_EtAlUseFirstOnCitationInBibliography nameattr_EtAlUseFirstOnCitationInCitation +nameattr_EtAlUseFirstOnNamesInBibliography nameattr_EtAlUseFirstOnNamesInCitation +nameattr_EtAlUseFirstOnStyleInBibliography nameattr_EtAlUseFirstOnStyleInCitation +nameattr_InitializeWithOnBibliographyInBibliography nameattr_InitializeWithOnBibliographyInCitation +nameattr_InitializeWithOnCitationInBibliography nameattr_InitializeWithOnCitationInCitation +nameattr_InitializeWithOnNamesInBibliography nameattr_InitializeWithOnNamesInCitation +nameattr_InitializeWithOnStyleInBibliography nameattr_InitializeWithOnStyleInCitation +nameattr_NameAsSortOrderOnBibliographyInBibliography nameattr_NameAsSortOrderOnBibliographyInCitation +nameattr_NameAsSortOrderOnCitationInBibliography nameattr_NameAsSortOrderOnCitationInCitation +nameattr_NameAsSortOrderOnNamesInBibliography nameattr_NameAsSortOrderOnNamesInCitation +nameattr_NameAsSortOrderOnStyleInBibliography nameattr_NameAsSortOrderOnStyleInCitation +nameattr_NameDelimiterOnBibliographyInBibliography nameattr_NameDelimiterOnBibliographyInCitation +nameattr_NameDelimiterOnCitationInBibliography nameattr_NameDelimiterOnCitationInCitation +nameattr_NameDelimiterOnNamesInBibliography nameattr_NameDelimiterOnNamesInCitation +nameattr_NameDelimiterOnStyleInBibliography nameattr_NameDelimiterOnStyleInCitation +nameattr_NameFormOnBibliographyInBibliography nameattr_NameFormOnBibliographyInCitation +nameattr_NameFormOnCitationInBibliography nameattr_NameFormOnCitationInCitation +nameattr_NameFormOnNamesInBibliography nameattr_NameFormOnNamesInCitation +nameattr_NameFormOnStyleInBibliography nameattr_NameFormOnStyleInCitation nameattr_NamesDelimiterOnBibliographyInCitation +nameattr_NamesDelimiterOnCitationInBibliography +nameattr_NamesDelimiterOnNamesInBibliography nameattr_NamesDelimiterOnNamesInCitation +nameattr_SortSeparatorOnBibliographyInBibliography nameattr_SortSeparatorOnBibliographyInCitation +nameattr_SortSeparatorOnCitationInBibliography nameattr_SortSeparatorOnCitationInCitation +nameattr_SortSeparatorOnNamesInBibliography nameattr_SortSeparatorOnNamesInCitation +nameattr_SortSeparatorOnStyleInBibliography nameattr_SortSeparatorOnStyleInCitation nameorder_Long nameorder_LongNameAsSortDemoteDisplayAndSort @@ -281,6 +358,7 @@ nameorder_Short nameorder_ShortDemoteDisplayAndSort nameorder_ShortNameAsSortDemoteNever namespaces_NonNada3 +number_FailingDelimiters number_IsNumericWithAlpha number_MixedPageRange number_PageFirst @@ -296,28 +374,48 @@ page_NumberPageFirst page_PluralDetectWithEndash page_WithLocaleAndWeirdDelimiter plural_LabelForced +position_IbidWithSuffix position_NearNoteUnsupported position_TrueInCitation punctuation_DateStripPeriods +punctuation_DelimiterWithStripPeriodsAndSubstitute1 +punctuation_DelimiterWithStripPeriodsAndSubstitute2 +punctuation_DelimiterWithStripPeriodsAndSubstitute3 punctuation_DoNotSuppressColonAfterPeriod punctuation_NoSuppressOfPeriodBeforeSemicolon +punctuation_SemicolonDelimiter +quotes_Punctuation +quotes_PunctuationNasty +sort_BibliographyResortOnUpdate +sort_CaseInsensitiveBibliography sort_CaseInsensitiveCitation sort_Citation +sort_CitationNumberPrimaryAscendingViaMacroBibliography +sort_CitationNumberPrimaryAscendingViaVariableBibliography sort_CitationSecondaryKey sort_CiteGroupDelimiter +sort_DaleDalebout +sort_DateMacroSortWithSecondFieldAlign sort_DateVariable sort_DateVariableMixedElementsAscendingA sort_DateVariableMixedElementsAscendingB sort_DateVariableMixedElementsDescendingA sort_DateVariableMixedElementsDescendingB +sort_EtAlUseLast +sort_FamilyOnly sort_LatinUnicode sort_LocalizedDateLimitedParts +sort_NameParticleInNameSortFalse +sort_NameParticleInNameSortTrue +sort_NamesUseLast +sort_StatusFieldDescending sort_TestInheritance sortseparator_SortSeparatorEmpty substitute_RepeatedNamesOk substitute_SubstituteOnlyOnceString substitute_SubstituteOnlyOnceTerm substitute_SubstituteOnlyOnceVariable +substitute_SuppressOrdinaryVariable textcase_AfterQuote textcase_CapitalsUntouched textcase_StopWordBeforeHyphen diff --git a/tests/citeproc.rs b/tests/citeproc.rs index 0f1f10e9..b3f09ce2 100644 --- a/tests/citeproc.rs +++ b/tests/citeproc.rs @@ -3,6 +3,7 @@ use std::io::{BufWriter, Write}; use std::path::PathBuf; use std::str::FromStr; +use std::sync::OnceLock; use std::{fmt, fs}; mod common; @@ -491,19 +492,17 @@ where .map_or(false, |d| d.end.is_some()) }); - if case.mode == TestMode::Bibliography { - if print { - eprintln!("Skipping test {}\t(cause: Bibliography mode)", display()); - } - false - } else if !can_test { + if !can_test { if print { eprintln!("Skipping test {}\t(cause: unsupported test feature)", display()); } false - } else if case.result.contains('<') { + } else if case.mode != TestMode::Bibliography && case.result.contains('<') { if print { - eprintln!("Skipping test {}\t(cause: HTML suspected)", display()); + eprintln!( + "Skipping test {}\t(cause: HTML suspected in citation result)", + display() + ); } false } else if contains_date_ranges { @@ -579,15 +578,39 @@ where let rendered = driver.finish(BibliographyRequest::new(&style, None, locales)); - for citation in rendered.citations { - citation - .citation - .write_buf(&mut output, hayagriva::BufWriteFormat::Plain) - .unwrap(); - output.push('\n'); - } + let formatted_result = match case.mode { + TestMode::Citation => { + for citation in rendered.citations { + citation + .citation + .write_buf(&mut output, hayagriva::BufWriteFormat::Plain) + .unwrap(); + output.push('\n'); + } - if output.trim() == case.result.trim() { + case.result.trim() + } + TestMode::Bibliography => { + static INDENT_REGEX: OnceLock = OnceLock::new(); + + let bib = rendered + .bibliography + .expect("Bibliography mode test but no bibliography was rendered"); + citeproc_bib::render(&bib, &mut output).unwrap(); + output.push('\n'); + + // Remove indentation from original result to match our own output, + // which is not indented or pretty-printed. It appears that + // citeproc test results simply indent elements with two spaces at + // the start when the line gets too long, or for each inner bib + // entry. + &*INDENT_REGEX + .get_or_init(|| regex::Regex::new(r#"\n\s*"#).unwrap()) + .replace_all(case.result.trim(), "") + } + }; + + if output.trim() == formatted_result { true } else { eprintln!("Test {} failed", display()); @@ -597,6 +620,161 @@ where } } +/// Functions to format bibliography rendered by hayagriva using citeproc's HTML +/// output format, in order to be able to compare the generated HTML. +mod citeproc_bib { + use core::fmt; + + use citationberg::{Display, FontStyle, FontVariant, FontWeight, VerticalAlign}; + use hayagriva::{BufWriteFormat, Elem, ElemChild, Formatting}; + + pub(super) fn render( + bib: &hayagriva::RenderedBibliography, + output: &mut String, + ) -> Result<(), fmt::Error> { + output.push_str(r#"
"#); + for item in &bib.items { + render_item(item, output)?; + } + output.push_str("
"); + Ok(()) + } + + fn render_item( + item: &hayagriva::BibliographyItem, + output: &mut String, + ) -> Result<(), fmt::Error> { + let mut second_field_align_suffix = ""; + output.push_str(r#"
"#); + if let Some(field) = &item.first_field { + // Uses 'second-field-align', so add implicit alignment + // (cf. test bugreports_AsmJournals.txt) + output.push_str("
"); + render_child(field, output)?; + output.push_str("
"); + second_field_align_suffix = "
"; + } + for child in &item.content.0 { + render_child(child, output)?; + } + output.push_str(second_field_align_suffix); + output.push_str("
"); + Ok(()) + } + + fn render_child(child: &ElemChild, output: &mut String) -> Result<(), fmt::Error> { + match child { + ElemChild::Text(formatted) => render_formatted_text(formatted, output), + ElemChild::Elem(e) => render_elem(e, output), + + // Citeproc bib tests do not output for links + ElemChild::Link { text, url: _ } => render_formatted_text(text, output), + elem => elem.write_buf(output, BufWriteFormat::Html), + } + } + + /// Applies the appropriate HTML tags to formatted text, according to + /// citeproc test output. + /// + /// Note that citeproc (csl-json) input accepts + /// '...' within text to nullify outer + /// formatting, for example apply 'font-style:normal' inside bold and + /// italics (see flipflop_ItalicsWithOk), or 'font-variant:normal' inside + /// smallcaps (see flipflop_ItalicsWithOkAndTextcase). We do not support + /// this notation, especially since we do not expose csl-json input to + /// hayagriva users anyway. + fn render_formatted_text( + text: &hayagriva::Formatted, + output: &mut String, + ) -> Result<(), fmt::Error> { + let formatting = text.formatting; + if formatting == Formatting::default() { + output.push_str(&text.text); + return Ok(()); + } + + // NOTE: There are no tests with multiple CSS styles, so for now we + // join them without any spacing. + let mut css = String::new(); + let mut suffix = String::new(); + let mut push_elem = |start, end| { + output.push_str(start); + suffix.insert_str(0, end); + }; + + match formatting.vertical_align { + VerticalAlign::Sub => { + push_elem("", ""); + } + VerticalAlign::Sup => { + push_elem("", ""); + } + VerticalAlign::Baseline => { + push_elem(r#""#, ""); + } + VerticalAlign::None => {} + } + + match formatting.font_weight { + FontWeight::Bold => { + push_elem("", ""); + } + FontWeight::Light => { + // NOTE: This is not used in any tests, so we can only assume + // this is done through this CSS style for now. + css.push_str("font-weight:lighter;"); + } + FontWeight::Normal => {} + } + + match formatting.font_style { + FontStyle::Italic => { + // NOTE: This has to come after (be inside) bold + // (Citeproc tests use ... when both are present) + push_elem("", "") + } + FontStyle::Normal => {} + } + + match formatting.font_variant { + FontVariant::SmallCaps => css.push_str("font-variant:small-caps;"), + FontVariant::Normal => {} + } + + if !css.is_empty() { + push_elem(&format!(""), ""); + } + + output.push_str(&text.text); + output.push_str(&suffix); + Ok(()) + } + + fn render_elem(elem: &Elem, output: &mut String) -> Result<(), fmt::Error> { + let mut div_suffix = ""; + if let Some(display) = elem.display { + div_suffix = ""; + let div_class = match display { + Display::Block => "csl-block", + Display::LeftMargin => "csl-left-margin", + Display::RightInline => "csl-right-inline", + Display::Indent => "csl-indent", + }; + + output.push_str(&format!("
")); + } + + for child in &elem.children.0 { + render_child(child, output)?; + } + + if !div_suffix.is_empty() { + output.push_str(div_suffix); + } + Ok(()) + } +} + #[test] fn purposes() { let style = ArchivedStyle::by_name("apa").unwrap().get(); @@ -723,7 +901,7 @@ fn case_folding() { .content .write_buf(&mut buf, hayagriva::BufWriteFormat::Plain) .unwrap(); - assert_eq!(buf, ". my lowercase container title"); + assert_eq!(buf, ". my lowercase container title."); } #[test]