From 5f2b3c3989f1bf092acd90535b3e90f53b482096 Mon Sep 17 00:00:00 2001 From: Randy Wilson Date: Mon, 15 Dec 2025 10:50:53 -0700 Subject: [PATCH] Add support for alternate calendar dates. --- .../conclusion/AlternateCalendarDate.java | 87 +++++++++ .../java/org/gedcomx/conclusion/Date.java | 66 ++++++- .../gedcomx/rt/GedcomxModelVisitorBase.java | 11 +- .../java/org/gedcomx/types/CalendarType.java | 166 ++++++++++++++++++ .../conclusion/AlternateCalendarDateTest.java | 124 +++++++++++++ .../java/org/gedcomx/util/MarshalUtil.java | 46 +++-- 6 files changed, 482 insertions(+), 18 deletions(-) create mode 100644 gedcomx-model/src/main/java/org/gedcomx/conclusion/AlternateCalendarDate.java create mode 100644 gedcomx-model/src/main/java/org/gedcomx/types/CalendarType.java create mode 100644 gedcomx-model/src/test/java/org/gedcomx/conclusion/AlternateCalendarDateTest.java diff --git a/gedcomx-model/src/main/java/org/gedcomx/conclusion/AlternateCalendarDate.java b/gedcomx-model/src/main/java/org/gedcomx/conclusion/AlternateCalendarDate.java new file mode 100644 index 000000000..d2e54dd47 --- /dev/null +++ b/gedcomx-model/src/main/java/org/gedcomx/conclusion/AlternateCalendarDate.java @@ -0,0 +1,87 @@ +/** + * Copyright Intellectual Reserve, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.gedcomx.conclusion; + +import org.gedcomx.types.CalendarType; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.webcohesion.enunciate.metadata.qname.XmlQNameEnumRef; + +/** + * Represents a date expressed in an alternate calendar system. + *

+ * An {@code AlternateCalendarDate} is included within a {@link Date} object's alternateCalendars list to express the + * same date in a calendar other than the proleptic Gregorian calendar. The main {@code Date} should always be + * in the proleptic Gregorian calendar, and any alternate representations (e.g., Hebrew, Rumi, Julian) + * should be provided as {@code AlternateCalendarDate} instances. + *

+ */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AlternateCalendarDate extends Date { + public AlternateCalendarDate() { + // Default constructor + } + + /** + * The calendar system used to interpret this date (e.g., Hebrew, Rumi, Julian). + * This value indicates which calendar should be used to interpret the date values in this object. + */ + private CalendarType calendar; + + public AlternateCalendarDate(CalendarType calendar) { + this.calendar = calendar; + } + + public AlternateCalendarDate(AlternateCalendarDate copy) { + super(copy); + this.calendar = copy.calendar; + } + + /** + * Gets the calendar system used to interpret this date. + * + * @return The calendar system for this alternate date. + */ + @XmlQNameEnumRef(CalendarType.class) + public CalendarType getCalendar() { + return calendar; + } + + /** + * Sets the calendar system used to interpret this date. + * + * @param calendar The calendar system for this alternate date. + */ + public void setCalendar(CalendarType calendar) { + this.calendar = calendar; + } + + /** + * Build up the AlternateCalendarDate with the calendar system. + * + * @param calendar The calendar system for this alternate date. + * @return this. + */ + public AlternateCalendarDate calendar(CalendarType calendar) { + setCalendar(calendar); + return this; + } + + @Override + public String toString() { + return "Date{" + "original='" + getOriginal() + '\'' + ", formal=" + getFormal() + ", calendar=" + calendar + '}'; + } +} diff --git a/gedcomx-model/src/main/java/org/gedcomx/conclusion/Date.java b/gedcomx-model/src/main/java/org/gedcomx/conclusion/Date.java index bbbcf2bcc..9f86a08fb 100644 --- a/gedcomx-model/src/main/java/org/gedcomx/conclusion/Date.java +++ b/gedcomx-model/src/main/java/org/gedcomx/conclusion/Date.java @@ -37,16 +37,16 @@ import jakarta.xml.bind.annotation.XmlTransient; import jakarta.xml.bind.annotation.XmlType; -import java.util.ArrayList; import java.util.LinkedList; import java.util.List; +import java.util.stream.Collectors; import java.util.stream.Stream; /** * A concluded genealogical date. */ @ClientName ("DateInfo") -@XmlType ( name = "Date", propOrder = { "original", "formal", "normalizedExtensions", "fields"}) +@XmlType ( name = "Date", propOrder = { "original", "formal", "alternateCalendars", "normalizedExtensions", "fields"}) @JsonInclude ( JsonInclude.Include.NON_NULL ) @Schema(description = "A concluded genealogical date.") public class Date extends ExtensibleData implements HasFields { @@ -63,6 +63,7 @@ public class Date extends ExtensibleData implements HasFields { @Schema(description = "The list of normalized values for the date, provided for display purposes by the application. Normalized values are not specified by " + "GEDCOM X core, but as extension elements by GEDCOM X RS.") private List normalized; + private List alternateCalendars; private List fields; @@ -74,8 +75,9 @@ public Date(Date copy) { this.original = copy.original; this.formal = copy.formal; this.confidence = copy.confidence; - this.normalized = copy.normalized == null ? null : new ArrayList<>(copy.normalized.stream().map(TextValue::new).toList()); - this.fields = copy.fields == null ? null : new ArrayList<>(copy.fields.stream().map(Field::new).toList()); + this.normalized = copy.normalized == null ? null : copy.normalized.stream().map(TextValue::new).collect(Collectors.toList()); + this.alternateCalendars = copy.alternateCalendars == null ? null : copy.alternateCalendars.stream().map(AlternateCalendarDate::new).toList(); + this.fields = copy.fields == null ? null : copy.fields.stream().map(Field::new).toList(); } @Override @@ -275,12 +277,64 @@ public void setNormalizedExtensions(List normalized) { public void addNormalizedExtension(TextValue normalizedExtension) { if (normalizedExtension != null) { if (normalized == null) { - normalized = new LinkedList(); + normalized = new LinkedList<>(); } normalized.add(normalizedExtension); } } + /** + * Get a list of the same date expressed in alternate calendars. + * In records, this is useful for recording how the date appeared and what calendar is believed to be used for it. + * This helps with interpreting the date correctly, especially when converting to a standard Gregorian calendar. + * In trees, it can also be useful to include an alternate calendar representation of a date if the records the + * person appears in tend to use a calendar other than Gregorian. For example, a person born in 1910 in the + * Ottoman Empire may have their birth date recorded in the Rumi calendar in most records. Including this + * date as an alternate calendar date, in addition to the Gregorian date, can help researchers understand and + * verify the date more easily. + * + * @return List of dates in alternate calendars. + */ + @XmlElement( name = "alternateCalendar" ) + public List getAlternateCalendars() { + return alternateCalendars; + } + + /** + * Set the list of alternate calendar dates. + * + * @param alternateCalendars - List of alternateCalendars + */ + @JsonProperty( "alternateCalendars" ) + public void setAlternateCalendars(List alternateCalendars) { + this.alternateCalendars = alternateCalendars; + } + + /** + * Build up this date with the same date in an alternate calendar. + * + * @param alternateCalendar The same date expressed in an alternate calendar + * @return this. + */ + public Date alternateCalendar(AlternateCalendarDate alternateCalendar) { + addAlternateCalendar(alternateCalendar); + return this; + } + + /** + * Add the same date expressed in an alternate calendar. + * + * @param alternateCalendar The alternate calendar date to be added. + */ + public void addAlternateCalendar(AlternateCalendarDate alternateCalendar) { + if (alternateCalendar != null) { + if (alternateCalendars == null) { + alternateCalendars = new LinkedList<>(); + } + alternateCalendars.add(alternateCalendar); + } + } + /** * Get the fields being used as evidence. * @@ -322,7 +376,7 @@ public Date field(Field field) { public void addField(Field field) { if (field != null) { if (fields == null) { - fields = new LinkedList(); + fields = new LinkedList<>(); } fields.add(field); } diff --git a/gedcomx-model/src/main/java/org/gedcomx/rt/GedcomxModelVisitorBase.java b/gedcomx-model/src/main/java/org/gedcomx/rt/GedcomxModelVisitorBase.java index 8e5e63989..8bb363750 100644 --- a/gedcomx-model/src/main/java/org/gedcomx/rt/GedcomxModelVisitorBase.java +++ b/gedcomx-model/src/main/java/org/gedcomx/rt/GedcomxModelVisitorBase.java @@ -37,7 +37,7 @@ @XmlTransient public class GedcomxModelVisitorBase implements GedcomxModelVisitor { - protected final LinkedList contextStack = new LinkedList(); + protected final LinkedList contextStack = new LinkedList<>(); @Override public void visitGedcomx(Gedcomx gx) { @@ -463,6 +463,15 @@ public void visitDate(Date date) { } protected void visitComponents(Date date) { + List alternateCalendars = date.getAlternateCalendars(); + if (alternateCalendars != null) { + for (AlternateCalendarDate alternateCalendar : alternateCalendars) { + if (alternateCalendar != null) { + alternateCalendar.accept(this); + } + } + } + List fields = date.getFields(); if (fields != null) { for (Field field : fields) { diff --git a/gedcomx-model/src/main/java/org/gedcomx/types/CalendarType.java b/gedcomx-model/src/main/java/org/gedcomx/types/CalendarType.java new file mode 100644 index 000000000..52e136089 --- /dev/null +++ b/gedcomx-model/src/main/java/org/gedcomx/types/CalendarType.java @@ -0,0 +1,166 @@ +/** + * Copyright Intellectual Reserve, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.gedcomx.types; + +import com.webcohesion.enunciate.metadata.qname.XmlQNameEnum; + +/** + * Enumeration of calendar systems used for AlternateDate representations. + *

+ * Notes: + *

    + *
  • Descriptions here are concise overviews; actual conversion logic should be implemented + * in a dedicated date standardization service.
  • + *
  • Examples include original scripts with romanization and translation for clarity.
  • + *
+ */ +@XmlQNameEnum( + base = XmlQNameEnum.BaseType.URI +) +public enum CalendarType { + + /** + * GREGORIAN — Proleptic Gregorian calendar. + *

+ * The standard civil calendar used in most of the world today, and the default calendar + * for GEDCOM X dates. This calendar is used for all “formal” GedcomX dates, and is + * assumed whenever a date does not explicitly specify a calendar type. + *

+ *

+ * The Gregorian calendar was introduced in 1582 to replace the Julian calendar. + * Different countries adopted it at different times, but in computational + * contexts it is treated as proleptic, meaning that its rules are + * extended backward to dates earlier than its historical adoption. Leap years + * occur in years divisible by 4, except century years not divisible by 400 + * (e.g., 1900 is a common year; 2000 is a leap year). + *

+ *

Example: October 15, 2025 (Gregorian)

+ */ + Gregorian, + + /** + * JULIAN — Julian calendar. + *

+ * A solar calendar introduced by Julius Caesar in 45 BCE, with a simple leap-year + * rule: every fourth year has 366 days. It was used throughout Europe until it was + * gradually replaced by the Gregorian calendar at different times in different + * regions. + *

+ *

+ * In genealogical records, dates in the early modern period may appear in “Old Style” + * (Julian) form, sometimes written as dual dates (e.g., 10 February 1740/41) because + * the legal new year began on March 25. Any such interpretation or conversion is + * handled by a date standardization service. + *

+ *

Example: March 1, 1600 (Julian)

+ */ + Julian, + + /** + * FRENCH_REPUBLIC — French Republican calendar. + *

+ * A solar calendar introduced in 1792 with 12 months of 30 days named after seasonal features + * (e.g., Vendémiaire, Brumaire, Floréal), followed by 5 or 6 complementary days (sans‑culottides). + * Weeks are décades of 10 days. + *

+ *

Example (French): 10 Floréal an II + * (dix Floréal an deux; 10 Floréal, Year 2)

+ */ + FrenchRepublic, + + /** + * CHINESE IMPERIAL — Traditional Chinese imperial (regnal) dating based on the lunisolar calendar. + *

+ * Dates are expressed with reign era names 年号 (niánhào; reign title), counting years within + * an emperor’s reign. Months and days follow the traditional lunisolar calendar, which can include + * leap months 闰月 (rùnyuè; leap month). + *

+ *

Example (Chinese): 康熙四十五年 八月 初三 + * (Kāngxī sìshíwǔ nián bāyuè chū sān; Kangxi year 45, 8th month, day 3)

+ */ + ChineseImperial, + + /** + * JAPANESE IMPERIAL — Japanese era-based regnal dating alongside Gregorian. + *

+ * Uses era names 元号 (gengō; era name) to count years within an emperor’s reign; the current era is + * 令和 (Reiwa; Beautiful Harmony) since 2019. Dates are written as + * “[era] [year]年 [month]月 [day]日”. + *

+ *

Example (Japanese): 令和7年 3月 1日 + * (Reiwa 7‑nen 3‑gatsu 1‑nichi; March 1, Reiwa 7)

+ */ + JapaneseImperial, + + /** + * KOREAN IMPERIAL — Korean imperial (regnal) calendar. + *

+ * Uses era names from the Daehan Empire for regnal dating, expressed as + * “[era name] [year] [month] [day]”. The empire name: 대한제국 (Daehan Jeguk; Great Han Empire). + * Format mirrors East Asian regnal styles. + *

+ *

Example (Hangul): 광무 5년 3월 1일 (Gwangmu 5‑nyeon 3‑wol 1‑il; Gwangmu year 5, March 1)

+ */ + KoreanImperial, + + /** + * THAI SOLAR — Thai Buddhist solar calendar. + *

+ * Solar calendar aligned with Gregorian months and days; the year is counted in + * พุทธศักราช (Phutthasakkarat; Buddhist Era), where BE = CE + 543. + *

+ *

Example (Thai): วันที่ 1 มกราคม พ.ศ. 2568 + * (Wan thī 1 Mokkarakhom Phō.Sō. 2568; 1 January, BE 2568)

+ */ + ThaiSolar, + + /** + * Hebrew — Jewish lunisolar calendar. + *

+ * The Hebrew calendar הלוח העברי (ha-luach ha-ivri; the Hebrew calendar) is lunisolar: + * 12 months with a leap month added 7 times in a 19-year cycle (Metonic) to align + * lunar months with the solar year. Months begin at the new moon; religious observances + * follow the calendar’s rules for postponements and year lengths. + *

+ *

Example (Hebrew): י״ד תשרי תשפ״ה + * (14 Tishri 5785; 14th of Tishri, year 5785)

+ */ + Hebrew, + + /** + * ISLAMIC — Islamic (Hijri) lunar calendar. + *

+ * A purely lunar calendar: 12 months of 29 or 30 days, totaling 354 or 355 days per year. + * Month starts traditionally depending on local crescent sighting; civil variants may use + * astronomical calculation. Common name: اَلْتَقْوِيمُ ٱلْهِجْرِي (al‑taqwīm al‑hijrī; the Hijri calendar). + *

+ *

Example (Arabic): ١ رمضان ١٤٤٧ هـ (1 Ramaḍān 1447 AH; 1st of Ramadan, year 1447 of the Hijra)

+ */ + Islamic, + + /** + * RUMI — Rumi solar calendar (Turkish Ottoman Empire). + *

+ * A solar calendar used in late Ottoman administration, originally based on the Julian calendar. + * It employed fiscal year numbering and, in later reforms, incorporated Gregorian‑style adjustments + * while keeping Rumi year numbers. The name رومي (Rūmī; Roman/Byzantine) reflects its provenance. + *

+ *

Example: Rûmî 1325, Kanun‑i Evvel 1 + * (Rûmî 1325; December 1 in the Rumi year 1325)

+ */ + Rumi; +} + diff --git a/gedcomx-model/src/test/java/org/gedcomx/conclusion/AlternateCalendarDateTest.java b/gedcomx-model/src/test/java/org/gedcomx/conclusion/AlternateCalendarDateTest.java new file mode 100644 index 000000000..0fff9a3d9 --- /dev/null +++ b/gedcomx-model/src/test/java/org/gedcomx/conclusion/AlternateCalendarDateTest.java @@ -0,0 +1,124 @@ +package org.gedcomx.conclusion; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.gedcomx.Gedcomx; +import org.gedcomx.records.Field; +import org.gedcomx.records.FieldValue; +import org.gedcomx.records.HasFields; +import org.gedcomx.rt.json.GedcomJacksonModule; +import org.gedcomx.types.CalendarType; +import org.gedcomx.types.FieldType; +import org.gedcomx.types.FieldValueType; +import org.gedcomx.util.MarshalUtil; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class AlternateCalendarDateTest { + @Test + public void testAlternateCalendarDate() { + Date date = new Date(); + date.setOriginal("28 March 2021"); + date.setFormal("+2021-03-28"); + assertEquals("28 March 2021", date.getOriginal()); + assertNull(date.getAlternateCalendars()); + + AlternateCalendarDate altDate = new AlternateCalendarDate(CalendarType.Hebrew); + altDate.setOriginal("15 Nisan 5781"); + date.addAlternateCalendar(altDate); + assertEquals(1, date.getAlternateCalendars().size()); + assertEquals("15 Nisan 5781", altDate.getOriginal()); + assertEquals(CalendarType.Hebrew, altDate.getCalendar()); + } + + @Test + public void testBuilders() { + Date date = new Date(); + date.setOriginal("10 February 1741"); + date.setFormal("+1741-02-10"); + assertNull(date.getAlternateCalendars()); + + AlternateCalendarDate altDate = new AlternateCalendarDate(CalendarType.Hebrew) + .calendar(CalendarType.Julian); + altDate.original("10 February 1740"); + date.alternateCalendar(altDate); + assertEquals(1, date.getAlternateCalendars().size()); + assertEquals("10 February 1741", date.getOriginal()); + assertEquals("10 February 1740", altDate.getOriginal()); + assertEquals(CalendarType.Julian, altDate.getCalendar()); + } + + @Test + public void testMarshallAlternateDate() throws JsonProcessingException { + Date date = new Date(); + date.setOriginal("28 March 2021"); + date.setFormal("+2021-03-28"); + date.field(new Field().type(FieldType.Date) + .value(new FieldValue("28 March 2021").type(FieldValueType.Interpreted))); + + AlternateCalendarDate altDate = new AlternateCalendarDate(CalendarType.Hebrew); + altDate.setOriginal("15 Nisan 5781"); + altDate.field(new Field().type(FieldType.Date) + // Hebrew date in the original Hebrew script + .value(new FieldValue("ט״ו בְּנִיסָן ה׳תשפ״א").type(FieldValueType.Original)) + // Same Hebrew date romanized + .value(new FieldValue("15 Nisan 5781").type(FieldValueType.Interpreted))); + date.addAlternateCalendar(altDate); + + Gedcomx doc = new Gedcomx() + .person(new Person() + .fact(new Fact() + .date(date))); + //Test XML marshalling/unmarshalling + String xml = MarshalUtil.toXml(doc); + Gedcomx unmarshalledDoc = fromXml(xml); + checkAlternateDate(unmarshalledDoc, date, altDate); + + //Test JSON marshalling/unmarshalling + ObjectMapper mapper = GedcomJacksonModule.createObjectMapper(Gedcomx.class); + String json = mapper.writeValueAsString(doc); + Gedcomx unmarshalledJsonDoc = mapper.readValue(json, Gedcomx.class); + checkAlternateDate(unmarshalledJsonDoc, date, altDate); + + System.out.println("XML Output:\n" + xml); + System.out.println("JSON Output:\n" + json); + } + + private static void checkAlternateDate(Gedcomx unmarshalledDoc, Date date, AlternateCalendarDate altDate) { + Date unmarshalledDate = unmarshalledDoc.getPersons().get(0).getFacts().get(0).getDate(); + assertEquals(date.getOriginal(), unmarshalledDate.getOriginal()); + assertEquals(date.getFormal(), unmarshalledDate.getFormal()); + assertEquals(1, unmarshalledDate.getAlternateCalendars().size()); + checkfields(date, unmarshalledDate); + AlternateCalendarDate unmarshalledAltDate = unmarshalledDate.getAlternateCalendars().get(0); + assertEquals(altDate.getOriginal(), unmarshalledAltDate.getOriginal()); + assertEquals(altDate.getCalendar(), unmarshalledAltDate.getCalendar()); + checkfields(altDate, unmarshalledAltDate); + } + + private static void checkfields(HasFields expectedFields, HasFields actualFields) { + assertEquals(expectedFields.getFields().size(), actualFields.getFields().size()); + for (int i = 0; i < expectedFields.getFields().size(); i++) { + Field expectedField = expectedFields.getFields().get(i); + Field actualField = actualFields.getFields().get(i); + assertEquals(expectedField.getType(), actualField.getType()); + assertEquals(expectedField.getValues().size(), actualField.getValues().size()); + for (int j = 0; j < expectedField.getValues().size(); j++) { + FieldValue expectedValue = expectedField.getValues().get(j); + FieldValue actualValue = actualField.getValues().get(j); + assertEquals(expectedValue.getType(), actualValue.getType()); + assertEquals(expectedValue.getText(), actualValue.getText()); + } + } + } + + private Gedcomx fromXml(String xml) { + try { + return MarshalUtil.unmarshal(new java.io.ByteArrayInputStream(xml.getBytes())); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/gedcomx-model/src/test/java/org/gedcomx/util/MarshalUtil.java b/gedcomx-model/src/test/java/org/gedcomx/util/MarshalUtil.java index a3ad7f6c6..52c6b359b 100755 --- a/gedcomx-model/src/test/java/org/gedcomx/util/MarshalUtil.java +++ b/gedcomx-model/src/test/java/org/gedcomx/util/MarshalUtil.java @@ -8,6 +8,9 @@ import jakarta.xml.bind.Marshaller; import jakarta.xml.bind.Unmarshaller; import java.io.*; +import javax.xml.transform.*; +import javax.xml.transform.stream.StreamResult; +import javax.xml.transform.stream.StreamSource; /** * Convenience class for converting a GedcomX document or RecordSet to and from XML. @@ -30,7 +33,11 @@ public class MarshalUtil { private static Marshaller getMarshaller(boolean prettyFormat) throws JAXBException { Marshaller m = jxbc.createMarshaller(); m.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, prettyFormat); - m.setProperty("com.sun.xml.bind.indentString", " "); + try { + m.setProperty("com.sun.xml.bind.indentString", " "); + } catch (Exception e) { + // Ignore if not supported by the JAXB implementation + } return m; } @@ -44,9 +51,8 @@ public static Unmarshaller createUnmarshaller() throws JAXBException { * @param recordSet - RecordSet to write * @param prettyFormat - flag for whether to use newlines and indentation in the output * @throws JAXBException - * @throws java.io.IOException */ - public static void output(OutputStream outputStream, RecordSet recordSet, boolean prettyFormat) throws JAXBException, IOException { + public static void output(OutputStream outputStream, RecordSet recordSet, boolean prettyFormat) throws JAXBException { getMarshaller(prettyFormat).marshal(recordSet, outputStream); } @@ -56,9 +62,8 @@ public static void output(OutputStream outputStream, RecordSet recordSet, boolea * @param doc - GedcomX document to write * @param prettyFormat - flag for whether to use newlines and indentation in the output * @throws JAXBException - * @throws IOException */ - public static void output(OutputStream outputStream, Gedcomx doc, boolean prettyFormat) throws JAXBException, IOException { + public static void output(OutputStream outputStream, Gedcomx doc, boolean prettyFormat) throws JAXBException { getMarshaller(prettyFormat).marshal(doc, outputStream); } @@ -80,9 +85,9 @@ public static Gedcomx unmarshal(InputStream inputStream) throws JAXBException { public static String toXml(Gedcomx doc) { try { StringWriter sw = new StringWriter(); - getMarshaller(true).marshal(doc, sw); - return sw.toString(); - } catch (JAXBException e) { + getMarshaller(false).marshal(doc, sw); // Disable JAXB pretty-printing + return formatXml(sw.toString(), 2); + } catch (Exception e) { e.printStackTrace(); } return null; @@ -96,11 +101,30 @@ public static String toXml(Gedcomx doc) { public static String toXml(RecordSet recordSet) { try { StringWriter sw = new StringWriter(); - getMarshaller(true).marshal(recordSet, sw); - return sw.toString(); - } catch (JAXBException e) { + getMarshaller(false).marshal(recordSet, sw); // Disable JAXB pretty-printing + return formatXml(sw.toString(), 2); + } catch (Exception e) { e.printStackTrace(); } return null; } + + /** + * Utility to format XML with a specified indent (in spaces). + */ + private static String formatXml(String xml, int indent) throws Exception { + TransformerFactory factory = TransformerFactory.newInstance(); + try { + factory.setAttribute("indent-number", indent); + } catch (IllegalArgumentException ignored) { + // Some factories do not support this attribute + } + Transformer transformer = factory.newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no"); + StringWriter writer = new StringWriter(); + transformer.transform(new StreamSource(new StringReader(xml)), new StreamResult(writer)); + // Collapse multiple newlines into a single newline to avoid extra blank lines + return writer.toString().replaceAll("(\r?\n){2,}", "\n"); + } }