2a8d5c5d4f5e556ca56ac0bf29a0f3f393b5cd5b
[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 void
503 SubtitleAsset::add (shared_ptr<Subtitle> s)
504 {
505         _subtitles.push_back (s);
506 }
507
508
509 Time
510 SubtitleAsset::latest_subtitle_out () const
511 {
512         Time t;
513         for (auto i: _subtitles) {
514                 if (i->out() > t) {
515                         t = i->out ();
516                 }
517         }
518
519         return t;
520 }
521
522
523 bool
524 SubtitleAsset::equals(shared_ptr<const Asset> other_asset, EqualityOptions const& options, NoteHandler note) const
525 {
526         if (!Asset::equals (other_asset, options, note)) {
527                 return false;
528         }
529
530         auto other = dynamic_pointer_cast<const SubtitleAsset> (other_asset);
531         if (!other) {
532                 return false;
533         }
534
535         if (_subtitles.size() != other->_subtitles.size()) {
536                 note (NoteType::ERROR, String::compose("different number of subtitles: %1 vs %2", _subtitles.size(), other->_subtitles.size()));
537                 return false;
538         }
539
540         auto i = _subtitles.begin();
541         auto j = other->_subtitles.begin();
542
543         while (i != _subtitles.end()) {
544                 auto string_i = dynamic_pointer_cast<SubtitleString> (*i);
545                 auto string_j = dynamic_pointer_cast<SubtitleString> (*j);
546                 auto image_i = dynamic_pointer_cast<SubtitleImage> (*i);
547                 auto image_j = dynamic_pointer_cast<SubtitleImage> (*j);
548
549                 if ((string_i && !string_j) || (image_i && !image_j)) {
550                         note (NoteType::ERROR, "subtitles differ: string vs. image");
551                         return false;
552                 }
553
554                 if (string_i && !string_i->equals(string_j, options, note)) {
555                         return false;
556                 }
557
558                 if (image_i && !image_i->equals(image_j, options, note)) {
559                         return false;
560                 }
561
562                 ++i;
563                 ++j;
564         }
565
566         return true;
567 }
568
569
570 struct SubtitleSorter
571 {
572         bool operator() (shared_ptr<Subtitle> a, shared_ptr<Subtitle> b) {
573                 if (a->in() != b->in()) {
574                         return a->in() < b->in();
575                 }
576                 if (a->v_align() == VAlign::BOTTOM) {
577                         return a->v_position() > b->v_position();
578                 }
579                 return a->v_position() < b->v_position();
580         }
581 };
582
583
584 void
585 SubtitleAsset::pull_fonts (shared_ptr<order::Part> part)
586 {
587         if (part->children.empty ()) {
588                 return;
589         }
590
591         /* Pull up from children */
592         for (auto i: part->children) {
593                 pull_fonts (i);
594         }
595
596         if (part->parent) {
597                 /* Establish the common font features that each of part's children have;
598                    these features go into part's font.
599                 */
600                 part->font = part->children.front()->font;
601                 for (auto i: part->children) {
602                         part->font.take_intersection (i->font);
603                 }
604
605                 /* Remove common values from part's children's fonts */
606                 for (auto i: part->children) {
607                         i->font.take_difference (part->font);
608                 }
609         }
610
611         /* Merge adjacent children with the same font */
612         auto i = part->children.begin();
613         vector<shared_ptr<order::Part>> merged;
614
615         while (i != part->children.end()) {
616
617                 if ((*i)->font.empty ()) {
618                         merged.push_back (*i);
619                         ++i;
620                 } else {
621                         auto j = i;
622                         ++j;
623                         while (j != part->children.end() && (*i)->font == (*j)->font) {
624                                 ++j;
625                         }
626                         if (std::distance (i, j) == 1) {
627                                 merged.push_back (*i);
628                                 ++i;
629                         } else {
630                                 shared_ptr<order::Part> group (new order::Part (part, (*i)->font));
631                                 for (auto k = i; k != j; ++k) {
632                                         (*k)->font.clear ();
633                                         group->children.push_back (*k);
634                                 }
635                                 merged.push_back (group);
636                                 i = j;
637                         }
638                 }
639         }
640
641         part->children = merged;
642 }
643
644
645 /** @param standard Standard (INTEROP or SMPTE); this is used rather than putting things in the child
646  *  class because the differences between the two are fairly subtle.
647  */
648 void
649 SubtitleAsset::subtitles_as_xml (xmlpp::Element* xml_root, int time_code_rate, Standard standard) const
650 {
651         auto sorted = _subtitles;
652         std::stable_sort(sorted.begin(), sorted.end(), SubtitleSorter());
653
654         /* Gather our subtitles into a hierarchy of Subtitle/Text/String objects, writing
655            font information into the bottom level (String) objects.
656         */
657
658         auto root = make_shared<order::Part>(shared_ptr<order::Part>());
659         shared_ptr<order::Subtitle> subtitle;
660         shared_ptr<order::Text> text;
661
662         Time last_in;
663         Time last_out;
664         Time last_fade_up_time;
665         Time last_fade_down_time;
666         HAlign last_h_align;
667         float last_h_position;
668         VAlign last_v_align;
669         float last_v_position;
670         float last_z_position;
671         Direction last_direction;
672
673         for (auto i: sorted) {
674                 if (!subtitle ||
675                     (last_in != i->in() ||
676                      last_out != i->out() ||
677                      last_fade_up_time != i->fade_up_time() ||
678                      last_fade_down_time != i->fade_down_time())
679                         ) {
680
681                         subtitle = make_shared<order::Subtitle>(root, i->in(), i->out(), i->fade_up_time(), i->fade_down_time());
682                         root->children.push_back (subtitle);
683
684                         last_in = i->in ();
685                         last_out = i->out ();
686                         last_fade_up_time = i->fade_up_time ();
687                         last_fade_down_time = i->fade_down_time ();
688                         text.reset ();
689                 }
690
691                 auto is = dynamic_pointer_cast<SubtitleString>(i);
692                 if (is) {
693                         if (!text ||
694                             last_h_align != is->h_align() ||
695                             fabs(last_h_position - is->h_position()) > ALIGN_EPSILON ||
696                             last_v_align != is->v_align() ||
697                             fabs(last_v_position - is->v_position()) > ALIGN_EPSILON ||
698                             fabs(last_z_position - is->z_position()) > ALIGN_EPSILON ||
699                             last_direction != is->direction()
700                                 ) {
701                                 text = make_shared<order::Text>(subtitle, is->h_align(), is->h_position(), is->v_align(), is->v_position(), is->z_position(), is->direction());
702                                 subtitle->children.push_back (text);
703
704                                 last_h_align = is->h_align ();
705                                 last_h_position = is->h_position ();
706                                 last_v_align = is->v_align ();
707                                 last_v_position = is->v_position ();
708                                 last_z_position = is->z_position();
709                                 last_direction = is->direction ();
710                         }
711
712                         text->children.push_back (make_shared<order::String>(text, order::Font (is, standard), is->text(), is->space_before()));
713                 }
714
715                 auto ii = dynamic_pointer_cast<SubtitleImage>(i);
716                 if (ii) {
717                         text.reset ();
718                         subtitle->children.push_back (
719                                 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())
720                                 );
721                 }
722         }
723
724         /* Pull font changes as high up the hierarchy as we can */
725
726         pull_fonts (root);
727
728         /* Write XML */
729
730         order::Context context;
731         context.time_code_rate = time_code_rate;
732         context.standard = standard;
733         context.spot_number = 1;
734
735         root->write_xml (xml_root, context);
736 }
737
738
739 map<string, ArrayData>
740 SubtitleAsset::font_data () const
741 {
742         map<string, ArrayData> out;
743         for (auto const& i: _fonts) {
744                 out[i.load_id] = i.data;
745         }
746         return out;
747 }
748
749
750 map<string, boost::filesystem::path>
751 SubtitleAsset::font_filenames () const
752 {
753         map<string, boost::filesystem::path> out;
754         for (auto const& i: _fonts) {
755                 if (i.file) {
756                         out[i.load_id] = *i.file;
757                 }
758         }
759         return out;
760 }
761
762
763 /** Replace empty IDs in any <LoadFontId> and <Font> tags with
764  *  a dummy string.  Some systems give errors with empty font IDs
765  *  (see DCP-o-matic bug #1689).
766  */
767 void
768 SubtitleAsset::fix_empty_font_ids ()
769 {
770         bool have_empty = false;
771         vector<string> ids;
772         for (auto i: load_font_nodes()) {
773                 if (i->id == "") {
774                         have_empty = true;
775                 } else {
776                         ids.push_back (i->id);
777                 }
778         }
779
780         if (!have_empty) {
781                 return;
782         }
783
784         string const empty_id = unique_string (ids, "font");
785
786         for (auto i: load_font_nodes()) {
787                 if (i->id == "") {
788                         i->id = empty_id;
789                 }
790         }
791
792         for (auto i: _subtitles) {
793                 auto j = dynamic_pointer_cast<SubtitleString> (i);
794                 if (j && j->font() && j->font().get() == "") {
795                         j->set_font (empty_id);
796                 }
797         }
798 }
799
800
801 namespace {
802
803 struct State
804 {
805         int indent;
806         string xml;
807         int disable_formatting;
808 };
809
810 }
811
812
813 static
814 void
815 format_xml_node (xmlpp::Node const* node, State& state)
816 {
817         if (auto text_node = dynamic_cast<const xmlpp::TextNode*>(node)) {
818                 string content = text_node->get_content();
819                 boost::replace_all(content, "&", "&amp;");
820                 boost::replace_all(content, "<", "&lt;");
821                 boost::replace_all(content, ">", "&gt;");
822                 state.xml += content;
823         } else if (auto element = dynamic_cast<const xmlpp::Element*>(node)) {
824                 ++state.indent;
825
826                 auto children = element->get_children();
827                 auto const should_disable_formatting =
828                         std::any_of(
829                                 children.begin(), children.end(),
830                                 [](xmlpp::Node const* node) { return static_cast<bool>(dynamic_cast<const xmlpp::ContentNode*>(node)); }
831                                 ) || element->get_name() == "Text";
832
833                 if (!state.disable_formatting) {
834                         state.xml += "\n" + string(state.indent * 2, ' ');
835                 }
836
837                 state.xml += "<" + element->get_name();
838
839                 for (auto attribute: element->get_attributes()) {
840                         state.xml += String::compose(" %1=\"%2\"", attribute->get_name().raw(), attribute->get_value().raw());
841                 }
842
843                 if (children.empty()) {
844                         state.xml += "/>";
845                 } else {
846                         state.xml += ">";
847
848                         if (should_disable_formatting) {
849                                 ++state.disable_formatting;
850                         }
851
852                         for (auto child: children) {
853                                 format_xml_node(child, state);
854                         }
855
856                         if (!state.disable_formatting) {
857                                 state.xml += "\n" + string(state.indent * 2, ' ');
858                         }
859
860                         state.xml += String::compose("</%1>", element->get_name().raw());
861
862                         if (should_disable_formatting) {
863                                 --state.disable_formatting;
864                         }
865                 }
866
867                 --state.indent;
868         }
869 }
870
871
872 /** Format XML much as write_to_string_formatted() would do, except without adding any white space
873  *  to <Text> nodes.  This is an attempt to avoid changing what is actually displayed as subtitles
874  *  while also formatting the XML in such a way as to avoid DoM bug 2205.
875  *
876  *  xml_namespace is an optional namespace for the root node; it would be nicer to set this up with
877  *  set_namespace_declaration in the caller and then to extract it here but I couldn't find a way
878  *  to get all namespaces with the libxml++ API.
879  */
880 string
881 SubtitleAsset::format_xml(xmlpp::Document const& document, optional<pair<string, string>> xml_namespace)
882 {
883         auto root = document.get_root_node();
884
885         State state = {};
886         state.xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<" + root->get_name();
887
888         if (xml_namespace) {
889                 if (xml_namespace->first.empty()) {
890                         state.xml += String::compose(" xmlns=\"%1\"", xml_namespace->second);
891                 } else {
892                         state.xml += String::compose(" xmlns:%1=\"%2\"", xml_namespace->first, xml_namespace->second);
893                 }
894         }
895
896         for (auto attribute: root->get_attributes()) {
897                 state.xml += String::compose(" %1=\"%2\"", attribute->get_name().raw(), attribute->get_value().raw());
898         }
899
900         state.xml += ">";
901
902         for (auto child: document.get_root_node()->get_children()) {
903                 format_xml_node(child, state);
904         }
905
906         state.xml += String::compose("\n</%1>\n", root->get_name().raw());
907
908         return state.xml;
909 }
910
911
912 void
913 SubtitleAsset::ensure_font(string load_id, dcp::ArrayData data)
914 {
915         if (std::find_if(_fonts.begin(), _fonts.end(), [load_id](Font const& font) { return font.load_id == load_id; }) == _fonts.end()) {
916                 add_font(load_id, data);
917         }
918 }
919