Cleanup: pass EqualityOptions as const&
[libdcp.git] / src / subtitle_asset.cc
1 /*
2     Copyright (C) 2012-2021 Carl Hetherington <cth@carlh.net>
3
4     This file is part of libdcp.
5
6     libdcp is free software; you can redistribute it and/or modify
7     it under the terms of the GNU General Public License as published by
8     the Free Software Foundation; either version 2 of the License, or
9     (at your option) any later version.
10
11     libdcp is distributed in the hope that it will be useful,
12     but WITHOUT ANY WARRANTY; without even the implied warranty of
13     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14     GNU General Public License for more details.
15
16     You should have received a copy of the GNU General Public License
17     along with libdcp.  If not, see <http://www.gnu.org/licenses/>.
18
19     In addition, as a special exception, the copyright holders give
20     permission to link the code of portions of this program with the
21     OpenSSL library under certain conditions as described in each
22     individual source file, and distribute linked combinations
23     including the two.
24
25     You must obey the GNU General Public License in all respects
26     for all of the code used other than OpenSSL.  If you modify
27     file(s) with this exception, you may extend this exception to your
28     version of the file(s), but you are not obligated to do so.  If you
29     do not wish to do so, delete this exception statement from your
30     version.  If you delete this exception statement from all source
31     files in the program, then also delete it here.
32 */
33
34
35 /** @file  src/subtitle_asset.cc
36  *  @brief SubtitleAsset class
37  */
38
39
40 #include "raw_convert.h"
41 #include "compose.hpp"
42 #include "subtitle_asset.h"
43 #include "subtitle_asset_internal.h"
44 #include "util.h"
45 #include "xml.h"
46 #include "subtitle_string.h"
47 #include "subtitle_image.h"
48 #include "dcp_assert.h"
49 #include "load_font_node.h"
50 #include "reel_asset.h"
51 #include <asdcp/AS_DCP.h>
52 #include <asdcp/KM_util.h>
53 #include <libxml++/nodes/element.h>
54 #include <boost/algorithm/string.hpp>
55 #include <boost/lexical_cast.hpp>
56 #include <boost/shared_array.hpp>
57 #include <algorithm>
58
59
60 using std::cerr;
61 using std::cout;
62 using std::dynamic_pointer_cast;
63 using std::make_shared;
64 using std::map;
65 using std::pair;
66 using std::shared_ptr;
67 using std::string;
68 using std::vector;
69 using boost::lexical_cast;
70 using boost::optional;
71 using namespace dcp;
72
73
74 SubtitleAsset::SubtitleAsset ()
75 {
76
77 }
78
79
80 SubtitleAsset::SubtitleAsset (boost::filesystem::path file)
81         : Asset (file)
82 {
83
84 }
85
86
87 string
88 string_attribute (xmlpp::Element const * node, string name)
89 {
90         auto a = node->get_attribute (name);
91         if (!a) {
92                 throw XMLError (String::compose ("missing attribute %1", name));
93         }
94         return string (a->get_value ());
95 }
96
97
98 optional<string>
99 optional_string_attribute (xmlpp::Element const * node, string name)
100 {
101         auto a = node->get_attribute (name);
102         if (!a) {
103                 return {};
104         }
105         return string (a->get_value ());
106 }
107
108
109 optional<bool>
110 optional_bool_attribute (xmlpp::Element const * node, string name)
111 {
112         auto s = optional_string_attribute (node, name);
113         if (!s) {
114                 return {};
115         }
116
117         return (s.get() == "1" || s.get() == "yes");
118 }
119
120
121 template <class T>
122 optional<T>
123 optional_number_attribute (xmlpp::Element const * node, string name)
124 {
125         auto s = optional_string_attribute (node, name);
126         if (!s) {
127                 return boost::optional<T> ();
128         }
129
130         std::string t = s.get ();
131         boost::erase_all (t, " ");
132         return raw_convert<T> (t);
133 }
134
135
136 SubtitleAsset::ParseState
137 SubtitleAsset::font_node_state (xmlpp::Element const * node, Standard standard) const
138 {
139         ParseState ps;
140
141         if (standard == Standard::INTEROP) {
142                 ps.font_id = optional_string_attribute (node, "Id");
143         } else {
144                 ps.font_id = optional_string_attribute (node, "ID");
145         }
146         ps.size = optional_number_attribute<int64_t> (node, "Size");
147         ps.aspect_adjust = optional_number_attribute<float> (node, "AspectAdjust");
148         ps.italic = optional_bool_attribute (node, "Italic");
149         ps.bold = optional_string_attribute(node, "Weight").get_value_or("normal") == "bold";
150         if (standard == Standard::INTEROP) {
151                 ps.underline = optional_bool_attribute (node, "Underlined");
152         } else {
153                 ps.underline = optional_bool_attribute (node, "Underline");
154         }
155         auto c = optional_string_attribute (node, "Color");
156         if (c) {
157                 ps.colour = Colour (c.get ());
158         }
159         auto const e = optional_string_attribute (node, "Effect");
160         if (e) {
161                 ps.effect = string_to_effect (e.get ());
162         }
163         c = optional_string_attribute (node, "EffectColor");
164         if (c) {
165                 ps.effect_colour = Colour (c.get ());
166         }
167
168         return ps;
169 }
170
171 void
172 SubtitleAsset::position_align (SubtitleAsset::ParseState& ps, xmlpp::Element const * node) const
173 {
174         auto hp = optional_number_attribute<float> (node, "HPosition");
175         if (!hp) {
176                 hp = optional_number_attribute<float> (node, "Hposition");
177         }
178         if (hp) {
179                 ps.h_position = hp.get () / 100;
180         }
181
182         auto ha = optional_string_attribute (node, "HAlign");
183         if (!ha) {
184                 ha = optional_string_attribute (node, "Halign");
185         }
186         if (ha) {
187                 ps.h_align = string_to_halign (ha.get ());
188         }
189
190         auto vp = optional_number_attribute<float> (node, "VPosition");
191         if (!vp) {
192                 vp = optional_number_attribute<float> (node, "Vposition");
193         }
194         if (vp) {
195                 ps.v_position = vp.get () / 100;
196         }
197
198         auto va = optional_string_attribute (node, "VAlign");
199         if (!va) {
200                 va = optional_string_attribute (node, "Valign");
201         }
202         if (va) {
203                 ps.v_align = string_to_valign (va.get ());
204         }
205
206         auto zp = optional_number_attribute<float>(node, "Zposition");
207         if (zp) {
208                 ps.z_position = zp.get() / 100;
209         }
210 }
211
212
213 SubtitleAsset::ParseState
214 SubtitleAsset::text_node_state (xmlpp::Element const * node) const
215 {
216         ParseState ps;
217
218         position_align (ps, node);
219
220         auto d = optional_string_attribute (node, "Direction");
221         if (d) {
222                 ps.direction = string_to_direction (d.get ());
223         }
224
225         ps.type = ParseState::Type::TEXT;
226
227         return ps;
228 }
229
230
231 SubtitleAsset::ParseState
232 SubtitleAsset::image_node_state (xmlpp::Element const * node) const
233 {
234         ParseState ps;
235
236         position_align (ps, node);
237
238         ps.type = ParseState::Type::IMAGE;
239
240         return ps;
241 }
242
243
244 SubtitleAsset::ParseState
245 SubtitleAsset::subtitle_node_state (xmlpp::Element const * node, optional<int> tcr) const
246 {
247         ParseState ps;
248         ps.in = Time (string_attribute(node, "TimeIn"), tcr);
249         ps.out = Time (string_attribute(node, "TimeOut"), tcr);
250         ps.fade_up_time = fade_time (node, "FadeUpTime", tcr);
251         ps.fade_down_time = fade_time (node, "FadeDownTime", tcr);
252         return ps;
253 }
254
255
256 Time
257 SubtitleAsset::fade_time (xmlpp::Element const * node, string name, optional<int> tcr) const
258 {
259         auto const u = optional_string_attribute(node, name).get_value_or ("");
260         Time t;
261
262         if (u.empty ()) {
263                 t = Time (0, 0, 0, 20, 250);
264         } else if (u.find (":") != string::npos) {
265                 t = Time (u, tcr);
266         } else {
267                 t = Time (0, 0, 0, lexical_cast<int> (u), tcr.get_value_or(250));
268         }
269
270         if (t > Time (0, 0, 8, 0, 250)) {
271                 t = Time (0, 0, 8, 0, 250);
272         }
273
274         return t;
275 }
276
277
278 void
279 SubtitleAsset::parse_subtitles (xmlpp::Element const * node, vector<ParseState>& state, optional<int> tcr, Standard standard)
280 {
281         if (node->get_name() == "Font") {
282                 state.push_back (font_node_state (node, standard));
283         } else if (node->get_name() == "Subtitle") {
284                 state.push_back (subtitle_node_state (node, tcr));
285         } else if (node->get_name() == "Text") {
286                 state.push_back (text_node_state (node));
287         } else if (node->get_name() == "SubtitleList") {
288                 state.push_back (ParseState ());
289         } else if (node->get_name() == "Image") {
290                 state.push_back (image_node_state (node));
291         } else {
292                 throw XMLError ("unexpected node " + node->get_name());
293         }
294
295         float space_before = 0;
296
297         for (auto i: node->get_children()) {
298                 auto const v = dynamic_cast<xmlpp::ContentNode const *>(i);
299                 if (v) {
300                         maybe_add_subtitle (v->get_content(), state, space_before, standard);
301                         space_before = 0;
302                 }
303                 auto const e = dynamic_cast<xmlpp::Element const *>(i);
304                 if (e) {
305                         if (e->get_name() == "Space") {
306                                 if (node->get_name() != "Text") {
307                                         throw XMLError ("Space node found outside Text");
308                                 }
309                                 auto size = optional_string_attribute(e, "Size").get_value_or("0.5");
310                                 if (standard == dcp::Standard::INTEROP) {
311                                         boost::replace_all(size, "em", "");
312                                 }
313                                 space_before += raw_convert<float>(size);
314                         } else {
315                                 parse_subtitles (e, state, tcr, standard);
316                         }
317                 }
318         }
319
320         state.pop_back ();
321 }
322
323
324 void
325 SubtitleAsset::maybe_add_subtitle (string text, vector<ParseState> const & parse_state, float space_before, Standard standard)
326 {
327         auto wanted = [](ParseState const& ps) {
328                 return ps.type && (ps.type.get() == ParseState::Type::TEXT || ps.type.get() == ParseState::Type::IMAGE);
329         };
330
331         if (find_if(parse_state.begin(), parse_state.end(), wanted) == parse_state.end()) {
332                 return;
333         }
334
335         ParseState ps;
336         for (auto const& i: parse_state) {
337                 if (i.font_id) {
338                         ps.font_id = i.font_id.get();
339                 }
340                 if (i.size) {
341                         ps.size = i.size.get();
342                 }
343                 if (i.aspect_adjust) {
344                         ps.aspect_adjust = i.aspect_adjust.get();
345                 }
346                 if (i.italic) {
347                         ps.italic = i.italic.get();
348                 }
349                 if (i.bold) {
350                         ps.bold = i.bold.get();
351                 }
352                 if (i.underline) {
353                         ps.underline = i.underline.get();
354                 }
355                 if (i.colour) {
356                         ps.colour = i.colour.get();
357                 }
358                 if (i.effect) {
359                         ps.effect = i.effect.get();
360                 }
361                 if (i.effect_colour) {
362                         ps.effect_colour = i.effect_colour.get();
363                 }
364                 if (i.h_position) {
365                         ps.h_position = i.h_position.get();
366                 }
367                 if (i.h_align) {
368                         ps.h_align = i.h_align.get();
369                 }
370                 if (i.v_position) {
371                         ps.v_position = i.v_position.get();
372                 }
373                 if (i.v_align) {
374                         ps.v_align = i.v_align.get();
375                 }
376                 if (i.z_position) {
377                         ps.z_position = i.z_position.get();
378                 }
379                 if (i.direction) {
380                         ps.direction = i.direction.get();
381                 }
382                 if (i.in) {
383                         ps.in = i.in.get();
384                 }
385                 if (i.out) {
386                         ps.out = i.out.get();
387                 }
388                 if (i.fade_up_time) {
389                         ps.fade_up_time = i.fade_up_time.get();
390                 }
391                 if (i.fade_down_time) {
392                         ps.fade_down_time = i.fade_down_time.get();
393                 }
394                 if (i.type) {
395                         ps.type = i.type.get();
396                 }
397         }
398
399         if (!ps.in || !ps.out) {
400                 /* We're not in a <Subtitle> node; just ignore this content */
401                 return;
402         }
403
404         DCP_ASSERT (ps.type);
405
406         switch (ps.type.get()) {
407         case ParseState::Type::TEXT:
408                 _subtitles.push_back (
409                         make_shared<SubtitleString>(
410                                 ps.font_id,
411                                 ps.italic.get_value_or (false),
412                                 ps.bold.get_value_or (false),
413                                 ps.underline.get_value_or (false),
414                                 ps.colour.get_value_or (dcp::Colour (255, 255, 255)),
415                                 ps.size.get_value_or (42),
416                                 ps.aspect_adjust.get_value_or (1.0),
417                                 ps.in.get(),
418                                 ps.out.get(),
419                                 ps.h_position.get_value_or(0),
420                                 ps.h_align.get_value_or(HAlign::CENTER),
421                                 ps.v_position.get_value_or(0),
422                                 ps.v_align.get_value_or(VAlign::CENTER),
423                                 ps.z_position.get_value_or(0),
424                                 ps.direction.get_value_or (Direction::LTR),
425                                 text,
426                                 ps.effect.get_value_or (Effect::NONE),
427                                 ps.effect_colour.get_value_or (dcp::Colour (0, 0, 0)),
428                                 ps.fade_up_time.get_value_or(Time()),
429                                 ps.fade_down_time.get_value_or(Time()),
430                                 space_before
431                                 )
432                         );
433                 break;
434         case ParseState::Type::IMAGE:
435         {
436                 switch (standard) {
437                 case Standard::INTEROP:
438                         if (text.size() >= 4) {
439                                 /* Remove file extension */
440                                 text = text.substr(0, text.size() - 4);
441                         }
442                         break;
443                 case Standard::SMPTE:
444                         /* It looks like this urn:uuid: is required, but DoM wasn't expecting it (and not writing it)
445                          * until around 2.15.140 so I guess either:
446                          *   a) it is not (always) used in the field, or
447                          *   b) nobody noticed / complained.
448                          */
449                         if (text.substr(0, 9) == "urn:uuid:") {
450                                 text = text.substr(9);
451                         }
452                         break;
453                 }
454
455                 /* Add a subtitle with no image data and we'll fill that in later */
456                 _subtitles.push_back (
457                         make_shared<SubtitleImage>(
458                                 ArrayData(),
459                                 text,
460                                 ps.in.get(),
461                                 ps.out.get(),
462                                 ps.h_position.get_value_or(0),
463                                 ps.h_align.get_value_or(HAlign::CENTER),
464                                 ps.v_position.get_value_or(0),
465                                 ps.v_align.get_value_or(VAlign::CENTER),
466                                 ps.z_position.get_value_or(0),
467                                 ps.fade_up_time.get_value_or(Time()),
468                                 ps.fade_down_time.get_value_or(Time())
469                                 )
470                         );
471                 break;
472         }
473         }
474 }
475
476
477 vector<shared_ptr<const Subtitle>>
478 SubtitleAsset::subtitles () const
479 {
480         vector<shared_ptr<const Subtitle>> s;
481         for (auto i: _subtitles) {
482                 s.push_back (i);
483         }
484         return s;
485 }
486
487
488 vector<shared_ptr<const Subtitle>>
489 SubtitleAsset::subtitles_during (Time from, Time to, bool starting) const
490 {
491         vector<shared_ptr<const Subtitle>> s;
492         for (auto i: _subtitles) {
493                 if ((starting && from <= i->in() && i->in() < to) || (!starting && i->out() >= from && i->in() <= to)) {
494                         s.push_back (i);
495                 }
496         }
497
498         return s;
499 }
500
501
502 /* XXX: this needs a test */
503 vector<shared_ptr<const Subtitle>>
504 SubtitleAsset::subtitles_in_reel (shared_ptr<const dcp::ReelAsset> asset) const
505 {
506         auto frame_rate = asset->edit_rate().as_float();
507         auto start = dcp::Time(asset->entry_point().get_value_or(0), frame_rate, time_code_rate());
508         auto during = subtitles_during (start, start + dcp::Time(asset->intrinsic_duration(), frame_rate, time_code_rate()), false);
509
510         vector<shared_ptr<const dcp::Subtitle>> corrected;
511         for (auto i: during) {
512                 auto c = make_shared<dcp::Subtitle>(*i);
513                 c->set_in (c->in() - start);
514                 c->set_out (c->out() - start);
515                 corrected.push_back (c);
516         }
517
518         return corrected;
519 }
520
521
522 void
523 SubtitleAsset::add (shared_ptr<Subtitle> s)
524 {
525         _subtitles.push_back (s);
526 }
527
528
529 Time
530 SubtitleAsset::latest_subtitle_out () const
531 {
532         Time t;
533         for (auto i: _subtitles) {
534                 if (i->out() > t) {
535                         t = i->out ();
536                 }
537         }
538
539         return t;
540 }
541
542
543 bool
544 SubtitleAsset::equals(shared_ptr<const Asset> other_asset, EqualityOptions const& options, NoteHandler note) const
545 {
546         if (!Asset::equals (other_asset, options, note)) {
547                 return false;
548         }
549
550         auto other = dynamic_pointer_cast<const SubtitleAsset> (other_asset);
551         if (!other) {
552                 return false;
553         }
554
555         if (_subtitles.size() != other->_subtitles.size()) {
556                 note (NoteType::ERROR, String::compose("different number of subtitles: %1 vs %2", _subtitles.size(), other->_subtitles.size()));
557                 return false;
558         }
559
560         auto i = _subtitles.begin();
561         auto j = other->_subtitles.begin();
562
563         while (i != _subtitles.end()) {
564                 auto string_i = dynamic_pointer_cast<SubtitleString> (*i);
565                 auto string_j = dynamic_pointer_cast<SubtitleString> (*j);
566                 auto image_i = dynamic_pointer_cast<SubtitleImage> (*i);
567                 auto image_j = dynamic_pointer_cast<SubtitleImage> (*j);
568
569                 if ((string_i && !string_j) || (image_i && !image_j)) {
570                         note (NoteType::ERROR, "subtitles differ: string vs. image");
571                         return false;
572                 }
573
574                 if (string_i && !string_i->equals(string_j, options, note)) {
575                         return false;
576                 }
577
578                 if (image_i && !image_i->equals(image_j, options, note)) {
579                         return false;
580                 }
581
582                 ++i;
583                 ++j;
584         }
585
586         return true;
587 }
588
589
590 struct SubtitleSorter
591 {
592         bool operator() (shared_ptr<Subtitle> a, shared_ptr<Subtitle> b) {
593                 if (a->in() != b->in()) {
594                         return a->in() < b->in();
595                 }
596                 if (a->v_align() == VAlign::BOTTOM) {
597                         return a->v_position() > b->v_position();
598                 }
599                 return a->v_position() < b->v_position();
600         }
601 };
602
603
604 void
605 SubtitleAsset::pull_fonts (shared_ptr<order::Part> part)
606 {
607         if (part->children.empty ()) {
608                 return;
609         }
610
611         /* Pull up from children */
612         for (auto i: part->children) {
613                 pull_fonts (i);
614         }
615
616         if (part->parent) {
617                 /* Establish the common font features that each of part's children have;
618                    these features go into part's font.
619                 */
620                 part->font = part->children.front()->font;
621                 for (auto i: part->children) {
622                         part->font.take_intersection (i->font);
623                 }
624
625                 /* Remove common values from part's children's fonts */
626                 for (auto i: part->children) {
627                         i->font.take_difference (part->font);
628                 }
629         }
630
631         /* Merge adjacent children with the same font */
632         auto i = part->children.begin();
633         vector<shared_ptr<order::Part>> merged;
634
635         while (i != part->children.end()) {
636
637                 if ((*i)->font.empty ()) {
638                         merged.push_back (*i);
639                         ++i;
640                 } else {
641                         auto j = i;
642                         ++j;
643                         while (j != part->children.end() && (*i)->font == (*j)->font) {
644                                 ++j;
645                         }
646                         if (std::distance (i, j) == 1) {
647                                 merged.push_back (*i);
648                                 ++i;
649                         } else {
650                                 shared_ptr<order::Part> group (new order::Part (part, (*i)->font));
651                                 for (auto k = i; k != j; ++k) {
652                                         (*k)->font.clear ();
653                                         group->children.push_back (*k);
654                                 }
655                                 merged.push_back (group);
656                                 i = j;
657                         }
658                 }
659         }
660
661         part->children = merged;
662 }
663
664
665 /** @param standard Standard (INTEROP or SMPTE); this is used rather than putting things in the child
666  *  class because the differences between the two are fairly subtle.
667  */
668 void
669 SubtitleAsset::subtitles_as_xml (xmlpp::Element* xml_root, int time_code_rate, Standard standard) const
670 {
671         auto sorted = _subtitles;
672         std::stable_sort(sorted.begin(), sorted.end(), SubtitleSorter());
673
674         /* Gather our subtitles into a hierarchy of Subtitle/Text/String objects, writing
675            font information into the bottom level (String) objects.
676         */
677
678         auto root = make_shared<order::Part>(shared_ptr<order::Part>());
679         shared_ptr<order::Subtitle> subtitle;
680         shared_ptr<order::Text> text;
681
682         Time last_in;
683         Time last_out;
684         Time last_fade_up_time;
685         Time last_fade_down_time;
686         HAlign last_h_align;
687         float last_h_position;
688         VAlign last_v_align;
689         float last_v_position;
690         float last_z_position;
691         Direction last_direction;
692
693         for (auto i: sorted) {
694                 if (!subtitle ||
695                     (last_in != i->in() ||
696                      last_out != i->out() ||
697                      last_fade_up_time != i->fade_up_time() ||
698                      last_fade_down_time != i->fade_down_time())
699                         ) {
700
701                         subtitle = make_shared<order::Subtitle>(root, i->in(), i->out(), i->fade_up_time(), i->fade_down_time());
702                         root->children.push_back (subtitle);
703
704                         last_in = i->in ();
705                         last_out = i->out ();
706                         last_fade_up_time = i->fade_up_time ();
707                         last_fade_down_time = i->fade_down_time ();
708                         text.reset ();
709                 }
710
711                 auto is = dynamic_pointer_cast<SubtitleString>(i);
712                 if (is) {
713                         if (!text ||
714                             last_h_align != is->h_align() ||
715                             fabs(last_h_position - is->h_position()) > ALIGN_EPSILON ||
716                             last_v_align != is->v_align() ||
717                             fabs(last_v_position - is->v_position()) > ALIGN_EPSILON ||
718                             fabs(last_z_position - is->z_position()) > ALIGN_EPSILON ||
719                             last_direction != is->direction()
720                                 ) {
721                                 text = make_shared<order::Text>(subtitle, is->h_align(), is->h_position(), is->v_align(), is->v_position(), is->z_position(), is->direction());
722                                 subtitle->children.push_back (text);
723
724                                 last_h_align = is->h_align ();
725                                 last_h_position = is->h_position ();
726                                 last_v_align = is->v_align ();
727                                 last_v_position = is->v_position ();
728                                 last_z_position = is->z_position();
729                                 last_direction = is->direction ();
730                         }
731
732                         text->children.push_back (make_shared<order::String>(text, order::Font (is, standard), is->text(), is->space_before()));
733                 }
734
735                 auto ii = dynamic_pointer_cast<SubtitleImage>(i);
736                 if (ii) {
737                         text.reset ();
738                         subtitle->children.push_back (
739                                 make_shared<order::Image>(subtitle, ii->id(), ii->png_image(), ii->h_align(), ii->h_position(), ii->v_align(), ii->v_position(), ii->z_position())
740                                 );
741                 }
742         }
743
744         /* Pull font changes as high up the hierarchy as we can */
745
746         pull_fonts (root);
747
748         /* Write XML */
749
750         order::Context context;
751         context.time_code_rate = time_code_rate;
752         context.standard = standard;
753         context.spot_number = 1;
754
755         root->write_xml (xml_root, context);
756 }
757
758
759 map<string, ArrayData>
760 SubtitleAsset::font_data () const
761 {
762         map<string, ArrayData> out;
763         for (auto const& i: _fonts) {
764                 out[i.load_id] = i.data;
765         }
766         return out;
767 }
768
769
770 map<string, boost::filesystem::path>
771 SubtitleAsset::font_filenames () const
772 {
773         map<string, boost::filesystem::path> out;
774         for (auto const& i: _fonts) {
775                 if (i.file) {
776                         out[i.load_id] = *i.file;
777                 }
778         }
779         return out;
780 }
781
782
783 /** Replace empty IDs in any <LoadFontId> and <Font> tags with
784  *  a dummy string.  Some systems give errors with empty font IDs
785  *  (see DCP-o-matic bug #1689).
786  */
787 void
788 SubtitleAsset::fix_empty_font_ids ()
789 {
790         bool have_empty = false;
791         vector<string> ids;
792         for (auto i: load_font_nodes()) {
793                 if (i->id == "") {
794                         have_empty = true;
795                 } else {
796                         ids.push_back (i->id);
797                 }
798         }
799
800         if (!have_empty) {
801                 return;
802         }
803
804         string const empty_id = unique_string (ids, "font");
805
806         for (auto i: load_font_nodes()) {
807                 if (i->id == "") {
808                         i->id = empty_id;
809                 }
810         }
811
812         for (auto i: _subtitles) {
813                 auto j = dynamic_pointer_cast<SubtitleString> (i);
814                 if (j && j->font() && j->font().get() == "") {
815                         j->set_font (empty_id);
816                 }
817         }
818 }
819
820
821 namespace {
822
823 struct State
824 {
825         int indent;
826         string xml;
827         int disable_formatting;
828 };
829
830 }
831
832
833 static
834 void
835 format_xml_node (xmlpp::Node const* node, State& state)
836 {
837         if (auto text_node = dynamic_cast<const xmlpp::TextNode*>(node)) {
838                 string content = text_node->get_content();
839                 boost::replace_all(content, "&", "&amp;");
840                 boost::replace_all(content, "<", "&lt;");
841                 boost::replace_all(content, ">", "&gt;");
842                 state.xml += content;
843         } else if (auto element = dynamic_cast<const xmlpp::Element*>(node)) {
844                 ++state.indent;
845
846                 auto children = element->get_children();
847                 auto const should_disable_formatting =
848                         std::any_of(
849                                 children.begin(), children.end(),
850                                 [](xmlpp::Node const* node) { return static_cast<bool>(dynamic_cast<const xmlpp::ContentNode*>(node)); }
851                                 ) || element->get_name() == "Text";
852
853                 if (!state.disable_formatting) {
854                         state.xml += "\n" + string(state.indent * 2, ' ');
855                 }
856
857                 state.xml += "<" + element->get_name();
858
859                 for (auto attribute: element->get_attributes()) {
860                         state.xml += String::compose(" %1=\"%2\"", attribute->get_name().raw(), attribute->get_value().raw());
861                 }
862
863                 if (children.empty()) {
864                         state.xml += "/>";
865                 } else {
866                         state.xml += ">";
867
868                         if (should_disable_formatting) {
869                                 ++state.disable_formatting;
870                         }
871
872                         for (auto child: children) {
873                                 format_xml_node(child, state);
874                         }
875
876                         if (!state.disable_formatting) {
877                                 state.xml += "\n" + string(state.indent * 2, ' ');
878                         }
879
880                         state.xml += String::compose("</%1>", element->get_name().raw());
881
882                         if (should_disable_formatting) {
883                                 --state.disable_formatting;
884                         }
885                 }
886
887                 --state.indent;
888         }
889 }
890
891
892 /** Format XML much as write_to_string_formatted() would do, except without adding any white space
893  *  to <Text> nodes.  This is an attempt to avoid changing what is actually displayed as subtitles
894  *  while also formatting the XML in such a way as to avoid DoM bug 2205.
895  *
896  *  xml_namespace is an optional namespace for the root node; it would be nicer to set this up with
897  *  set_namespace_declaration in the caller and then to extract it here but I couldn't find a way
898  *  to get all namespaces with the libxml++ API.
899  */
900 string
901 SubtitleAsset::format_xml(xmlpp::Document const& document, optional<pair<string, string>> xml_namespace)
902 {
903         auto root = document.get_root_node();
904
905         State state = {};
906         state.xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<" + root->get_name();
907
908         if (xml_namespace) {
909                 if (xml_namespace->first.empty()) {
910                         state.xml += String::compose(" xmlns=\"%1\"", xml_namespace->second);
911                 } else {
912                         state.xml += String::compose(" xmlns:%1=\"%2\"", xml_namespace->first, xml_namespace->second);
913                 }
914         }
915
916         for (auto attribute: root->get_attributes()) {
917                 state.xml += String::compose(" %1=\"%2\"", attribute->get_name().raw(), attribute->get_value().raw());
918         }
919
920         state.xml += ">";
921
922         for (auto child: document.get_root_node()->get_children()) {
923                 format_xml_node(child, state);
924         }
925
926         state.xml += String::compose("\n</%1>\n", root->get_name().raw());
927
928         return state.xml;
929 }
930
931
932 void
933 SubtitleAsset::ensure_font(string load_id, dcp::ArrayData data)
934 {
935         if (std::find_if(_fonts.begin(), _fonts.end(), [load_id](Font const& font) { return font.load_id == load_id; }) == _fonts.end()) {
936                 add_font(load_id, data);
937         }
938 }
939