class TemplateField:  # pylint: disable=too-few-public-methods
    """ Represents a field in a template. """

    def __init__(self,
                 name: str=None,
                 context: str=None,
                 inner_content: str=None,
                 indices: range=None):
        self.name = name  # the name of the field
        self.context = context  # the context passed to the field name
        self.inner_content = inner_content  # the inner content between the field braces
        self.indices = indices  # the indices ranging from the first wrapping '{' to the last '}'

        if self.inner_content is None:
            if self.name is not None:
                if self.context is not None:
                    self.inner_content = self.name + ' ' + self.context
                else:
                    self.inner_content = self.name

    def __str__(self):
        return '{{ ' + (self.inner_content or '') + ' }}'

    def has_row_reference(self) -> bool:
        """ Determine whether a field holds a row reference. """

        return (self.context.startswith('#')
                if self.context is not None
                else False) def fields(content: str,
           with_name_like: str=None,
           with_context_like: str=None,
           strictly_matching: bool=True) -> Iterator[TemplateField]:
    """ Return an iterator for all fields (e.g. '{{ a_field }}') that occur in a template. """

    pattern = r'{{\s?(([^}}\s]*)\s?(.*?))\s?}}'

    for match in re.finditer(pattern, content):
        inner_content = match.group(1).strip()
        name = match.group(2).strip()
        context = match.group(3).strip()

        inner_content = inner_content if len(inner_content) > 0 else None
        name = name if len(name) > 0 else None
        context = context if len(context) > 0 else None

        field = TemplateField(
            name, context, inner_content, indices=range(
                match.start(), match.end()))

        satisfies_name_filter = (with_name_like is None or
                                 (with_name_like is not None and field.name is not None
                                  and re.search(with_name_like, field.name) is not None))

        satisfies_context_filter = (with_context_like is None or
                                    (with_context_like is not None and field.context is not None
                                     and re.search(with_context_like, field.context) is not None))

        satisfies_filter = (satisfies_name_filter and satisfies_context_filter
                            if strictly_matching
                            else satisfies_name_filter or satisfies_context_filter)

        if satisfies_filter:
            yield field class Row:
    """ Represents a row in a datasource. """

    def __init__(self,
                 data: dict,
                 data_path: str=None,
                 row_index: int=None,
                 is_excluded: bool=False):
        self.data = data
        self.data_path = data_path
        self.row_index = row_index
        self.is_excluded = is_excluded

    def _usable_columns(self):
        return (column for column in
                (Column(name, content) for name, content in self.data.items())
                if not column.is_excluded()
                and not column.is_special())

    def _both_data(self) -> dict:
        """ Return a dict containing items fit for both the front- and back of a card. """

        return {column.name: column.content for column in self._usable_columns()
                if not column.is_back_only() and not column.is_front_only()}

    def _front_data(self) -> dict:
        """ Return a dict containing only items fit for the front of a card. """

        front_only_data = {column.name[:-len(ColumnDescriptors.FRONT_ONLY)]: column.content for column
                           in self._usable_columns() if column.is_front_only()}

        return {**self._both_data(), **front_only_data}

    def _back_data(self) -> dict:
        """ Return a dict containing only items fit for the back of a card. """

        back_only_data = {column.name[:-len(ColumnDescriptors.BACK_ONLY)]: column.content for column
                          in self._usable_columns() if column.is_back_only()}

        return {**self._both_data(), **back_only_data}

    def front_row(self) -> 'Row':
        """ Return a Row containing only data fit for the front of a card. """

        return Row(data=self._front_data(),
                   data_path=self.data_path,
                   row_index=self.row_index)

    def back_row(self) -> 'Row':
        """ Return a Row containing only data fit for the back of a card. """

        return Row(data=self._back_data(),
                   data_path=self.data_path,
                   row_index=self.row_index)

    def is_prototype(self) -> bool:
        """ Determine whether the row is a prototype row. """

        return self.data.get(Columns.COUNT, '').strip() == '~'

    def determine_count(self) -> (int, bool):
        """ Determine and return count value from the @count column in a row. """

        # determine how many instances of this card to generate
        # (defaults to a single instance if not specified)
        count = self.data.get(Columns.COUNT, '1').strip()

        was_indeterminable = False

        if len(count) > 0:
            # the count column has content, so attempt to parse it
            try:
                count = int(count)
            except ValueError:
                # count could not be determined, so default to skip this card
                count = 0

                was_indeterminable = True
        else:
            # the count column did not have content, so default count to 1
            count = 1

        if count < 0:
            count = 0

            was_indeterminable = True

        return count, was_indeterminable

    @staticmethod
    def is_excluded(row_string: str) -> bool:
        """ Determine if a line in a file should be excluded. """

        return row_string.strip().startswith('#') def get_column_contentd(column: str,
                        in_row: Row,
                        definitions: dict,
                        content_resolver=None,
                        field_resolver=None) -> (str, ColumnResolutionData):
    """ Return the content of a column, recursively resolving any column/definition references. """

    # get the raw content of the column, optionally assigning a default value
    column_content = in_row.data.get(column, None)

    if column_content is None:
        # return early with default content
        return column_content, ColumnResolutionData()

    # strip excess whitespace
    column_content = column_content.strip()

    if len(column_content) == 0:
        # return early with default content
        return column_content, ColumnResolutionData()

    if content_resolver is not None:
        # the content resolver typically fills any include, date or empty fields
        column_content = content_resolver(column_content, in_row.data_path)

    resolved_column_content, resolution_data = resolve_column(
        Column(column, column_content), in_row, definitions, content_resolver, field_resolver)

    # transform content to html using any applied markdown formatting
    resolved_column_content = markdown(resolved_column_content)

    return resolved_column_content, resolution_data def get_definition_content(definition: str,
                           in_definitions: dict,
                           content_resolver=None,
                           field_resolver=None) -> str:
    """ Return the content of a definition, recursively resolving any references. """

    return get_definition_contentd(
        definition, in_definitions, content_resolver, field_resolver)[0] def get_definition_contentd(definition: str,
                            in_definitions: dict,
                            content_resolver=None,
                            field_resolver=None) -> (str, ColumnResolutionData):
    """ Return the content of a definition, recursively resolving any references. """

    definition_content, resolution_data = get_column_contentd(
        column=definition, in_row=Row(data=in_definitions), definitions=in_definitions,
        content_resolver=content_resolver, field_resolver=field_resolver)

    return definition_content, resolution_data def get_resource_path(resource_name: str) -> str:
    """ Get destination path for the resource. """

    return (os.path.join(get_resources_path(), resource_name)
            if resource_name is not None and len(resource_name) > 0
            else None) def is_image(image_path: str) -> bool:
    """ Determine whether a path points to an image. """

    return (image_path.strip().lower().endswith(supported_image_types())
            if image_path is not None
            else False) def supported_image_types() -> tuple:
    """ Return all supported and recognized image types. """

    return '.svg', '.png', '.jpg', '.jpeg', '.bmp' def transformed_image_paths(image_paths: List[str], relative_to_path: str) -> List[str]:
    return [os.path.join(os.path.dirname(relative_to_path), image_path)
            for image_path in image_paths] def first(iterable):
    """ Return the first object in an iterable, if any. """

    return next(iterable, None) def dequote(string: str) -> str:
    """ Return string by removing surrounding double or single quotes. """

    if (string[0] == string[-1]) and string.startswith(('\'', '"')):
        return string[1:-1]

    return string def get_line_number(from_index: int, in_string: str) -> int:
    """ Return the line number of which the character at an index in a string is located. """

    return in_string.count('\n', 0, from_index) + 1 def get_padded_string(string: str,
                      in_string: str,
                      from_char_index: int) -> str:
    """ Return a string that is appropriately padded/indented, given a starting position.

        For example, if a starting index of 4 is given for a string "    content\ngoes here",
        the resulting string becomes "    content\n    goes here".
    """

    pad_count = 0
    index = from_char_index

    while index >= 0:
        # keep going backwards in the string
        index -= 1

        if index < 0 or in_string[index] == '\n':
            # we found the previous line or beginning of string
            break

        pad_count += 1

    if pad_count > 0:
        # split content up into separate lines
        lines = string.splitlines(keepends=True)
        # then append padding between each line
        string = (' ' * pad_count).join(lines)
        # and get rid of any trailing newlines
        string = string.rstrip()

    return string class WarningDisplay:
    """ Provides functions for conveniently displaying and tracking warning/error messages. """

    messages = {}

    @staticmethod
    def has_displayed_messages() -> bool:
        return len(WarningDisplay.messages) > 0

    @staticmethod
    def has_encountered_errors() -> bool:
        return WarningDisplay.error_count > 0

    @staticmethod
    def has_encountered_warnings() -> bool:
        return WarningDisplay.warning_count > 0

    warning_count = 0
    error_count = 0

    is_verbose = False

    # lots of colors defined here: http://stackoverflow.com/a/21786287/144433

    apply_error_color = '\033[0;31m' if terminal_supports_color() else ''
    apply_error_color_underlined = '\033[4;31m' if terminal_supports_color() else ''
    apply_warning_color = '\033[0;33m' if terminal_supports_color() else ''
    apply_warning_color_underlined = '\033[4;33m' if terminal_supports_color() else ''
    apply_info_color = '\033[0;32m' if terminal_supports_color() else ''
    apply_info_color_underlined = '\033[4;32m' if terminal_supports_color() else ''
    apply_normal_color = '\033[0m' if terminal_supports_color() else ''
    apply_normal_color_underlined = '\033[4m' if terminal_supports_color() else ''

    @staticmethod
    def newer_version_available(new_version_identifier: str) -> None:
        display('A newer version is available ({0})'
                .format(new_version_identifier),
                apply_color=WarningDisplay.apply_info_color,
                force_verbosity=True)

    @staticmethod
    def could_not_make_new_project_error(at_destination_path: str,
                                         already_exists: bool=False,
                                         reason: str=None) -> None:
        if already_exists:
            warn('Could not create empty project at: {0}\'{1}\'{2}; the directory already exists'
                 .format(WarningDisplay.apply_error_color_underlined, at_destination_path,
                         WarningDisplay.apply_error_color),
                 as_error=True)
        else:
            if reason is not None and len(reason) > 0:
                warn('Could not create empty project at: {0}\'{1}\'{2}. {3}'
                     .format(WarningDisplay.apply_error_color_underlined, at_destination_path,
                             WarningDisplay.apply_error_color, reason),
                     as_error=True)
            else:
                warn('Could not create empty project at: {0}\'{1}\'{2}'
                     .format(WarningDisplay.apply_error_color_underlined, at_destination_path,
                             WarningDisplay.apply_error_color),
                     as_error=True)

    @staticmethod
    def no_datasources() -> None:
        warn('Specify at least one datasource (or a directory with datasources)',
             as_error=True)

    @staticmethod
    def unused_resources(resource_filenames: list, in_resource_dir: str) -> None:
        warn('Unused resources were found (in {0}\'{1}\'{2}): {3}'
             .format(WarningDisplay.apply_normal_color_underlined, in_resource_dir,
                     WarningDisplay.apply_normal_color, resource_filenames))

    @staticmethod
    def unused_resources_were_cleaned(resource_filenames: list, in_resource_dir: str) -> None:
        warn('Unused resources were found and removed (in {0}\'{1}\'{2}): {3}'
             .format(WarningDisplay.apply_normal_color_underlined, in_resource_dir,
                     WarningDisplay.apply_normal_color, resource_filenames))

    @staticmethod
    def resource_was_overwritten(context: WarningContext,
                                 resource_path: str,
                                 relative_source_path: str) -> None:
        warn('The resource \'{0}\' was overwritten by \'{1}\''
             .format(resource_path, relative_source_path),
             in_context=context)

    @staticmethod
    def potential_ambiguous_references(context: WarningContext,
                                       ambiguous_references: list) -> None:
        warn('You have ambiguous references that could refer to '
             'both a column or a definition: {0}'
             .format(ambiguous_references),
             in_context=context)

    @staticmethod
    def ambiguous_reference_used_column(context: WarningContext,
                                        reference: str,
                                        result: str) -> None:
        truncated_result = (result if len(result) < 18 else result[:18] + '…')

        warn('A reference named \'{0}\' could refer to both a column or a definition; '
             'the column data \'{1}\' was used'
             .format(reference, truncated_result),
             in_context=context)

    @staticmethod
    def ambiguous_reference_used_definition(context: WarningContext,
                                            reference: str,
                                            result: str) -> None:
        truncated_result = (result if len(result) < 18 else result[:18] + '…')

        warn('A reference named \'{0}\' could refer to both a column or a definition; '
             'the definition data \'{1}\' was used'
             .format(reference, truncated_result),
             in_context=context)

    @staticmethod
    def unresolved_infinite_definition_reference(context: WarningContext, definition: str) -> None:
        warn('The field \'{0}\' was not resolved; '
             'it is referencing its own definition and would cause infinite recursion'
             .format(definition),
             in_context=context)

    @staticmethod
    def unresolved_infinite_column_reference(context: WarningContext, column: str) -> None:
        warn('The field \'{0}\' was not resolved; '
             'it is referencing its own column and would cause infinite recursion'
             .format(column),
             in_context=context)

    @staticmethod
    def unresolved_reference(context: WarningContext, reference: str) -> None:
        warn('The field \'{0}\' could not be resolved'
             .format(reference),
             in_context=context)

    @staticmethod
    def unknown_size_specification(context: WarningContext,
                                   size_specification: str) -> None:
        warn('The size specification \'{0}\' has not been defined; '
             'the image might not display as expected'
             .format(size_specification),
             in_context=context)

    @staticmethod
    def invalid_width_specification(context: WarningContext,
                                    width: int) -> None:
        warn('An image cannot have a width of \'{0}\'; '
             'the image will be displayed at its intrinsic size'
             .format(width),
             in_context=context)

    @staticmethod
    def invalid_height_specification(context: WarningContext,
                                     height: int) -> None:
        warn('An image cannot have a height of \'{0}\'; '
             'the image will be displayed at its intrinsic size'
             .format(height),
             in_context=context)

    @staticmethod
    def unresolved_image_reference_error(image_reference: str,
                                         closest_resolution_value: str) -> None:
        warn('An image reference could not be resolved: \'{0}\'; '
             'was it supposed to be: \'{1}\'?'
             .format(image_reference, closest_resolution_value),
             as_error=True)

    @staticmethod
    def included_file_not_found_error(context: WarningContext,
                                      included_file_path: str) -> None:
        warn('An included file was not found: {0}\'{1}\'{2}'
             .format(WarningDisplay.apply_error_color_underlined, included_file_path,
                     WarningDisplay.apply_error_color),
             in_context=context,
             as_error=True)

    @staticmethod
    def include_should_specify_file(context: WarningContext, is_inline: bool=False) -> None:
        warn('{0} fields should specify a file path'
             .format('Inline' if is_inline else 'Include'),
             in_context=context)

    @staticmethod
    def preview_enabled_info() -> None:
        info('Preview is enabled; only 1 of each card will be rendered')

    @staticmethod
    def image_not_copied(context: WarningContext,
                         image_path: str) -> None:
        warn('An image was not copied to the output directory: {0}\'{1}\'{2}'
             .format(WarningDisplay.apply_warning_color_underlined, image_path,
                     WarningDisplay.apply_warning_color),
             in_context=context)

    @staticmethod
    def missing_image_error(context: WarningContext,
                            image_path: str) -> None:
        warn('One or more cards contain an image reference that does not exist: {0}\'{1}\'{2}'
             .format(WarningDisplay.apply_error_color_underlined, image_path,
                     WarningDisplay.apply_error_color),
             in_context=context,
             as_error=True)

    @staticmethod
    def bad_definitions_file_error(definitions_path: str) -> None:
        warn('No definitions file was found at: {0}\'{1}\'{2}'
             .format(WarningDisplay.apply_error_color_underlined, definitions_path,
                     WarningDisplay.apply_error_color),
             as_error=True)

    @staticmethod
    def bad_header_file_error(header_path: str) -> None:
        warn('No header template was found at: {0}\'{1}\'{2}'
             .format(WarningDisplay.apply_error_color_underlined, header_path,
                     WarningDisplay.apply_error_color),
             as_error=True)

    @staticmethod
    def using_automatically_found_definitions_info(definitions_path: str) -> None:
        info('No definitions have been specified; using definitions automatically found at: '
             '{0}\'{1}\'{2}'
             .format(WarningDisplay.apply_info_color_underlined, definitions_path,
                     WarningDisplay.apply_info_color))

    @staticmethod
    def assume_backs_info(context: WarningContext) -> None:
        info('Card backs will be generated since the ' +
             '\'' + Columns.TEMPLATE_BACK + '\' column has been set '
             '(you can disable card backs by specifying the --disable-backs option)',
             in_context=context)

    @staticmethod
    def no_backs_info(context: WarningContext) -> None:
        info('Card backs will not be generated since the '
             '\'' + Columns.TEMPLATE_BACK + '\' column has not been set',
             in_context=context)

    @staticmethod
    def indeterminable_count(context: WarningContext) -> None:
        warn('The card provided an indeterminable count and was skipped',
             in_context=context)

    @staticmethod
    def missing_default_template(context: WarningContext) -> None:
        warn('A template was not provided and auto-templating is not enabled; '
             'cards will not be generated correctly',
             in_context=context)

    @staticmethod
    def missing_template_error(context: WarningContext,
                               cards_affected: int) -> None:
        warn('The card did not provide a template',
             in_context=context, cards_affected=cards_affected,
             as_error=True)

    @staticmethod
    def empty_template(context: WarningContext,
                       template_path: str,
                       cards_affected: int,
                       is_back_template: bool=False) -> None:
        warning = ('The card provided a back template that appears to be empty: {0}\'{1}\'{2}'
                   if is_back_template else
                   'The card provided a template that appears to be empty: {0}\'{1}\'{2}; '
                   'using an auto-template instead')

        warn(warning.format(WarningDisplay.apply_warning_color_underlined, template_path,
                            WarningDisplay.apply_warning_color),
             in_context=context, cards_affected=cards_affected)

    @staticmethod
    def using_auto_template(context: WarningContext,
                            cards_affected: int) -> None:
        warn('The card did not provide a template; using an auto-template instead',
             in_context=context, cards_affected=cards_affected)

    @staticmethod
    def fields_in_styles(context: WarningContext, fields: list) -> None:
        warn('The template embeds a style that contains fields that might not be '
             'resolved properly: {0}'
             .format(fields),
             in_context=context)

    @staticmethod
    def unknown_fields_in_template(context: WarningContext,
                                   unknown_fields: list,
                                   template_path: str,
                                   cards_affected: int,
                                   is_back_template: bool=False) -> None:
        if len(unknown_fields) > 1:
            msg = ('The back template ({0}) contains fields that are not present for this card, '
                   'or could not be resolved: {1}'
                   if is_back_template else
                   'The template ({0}) contains fields that are not present for this card, '
                   'or could not be resolved: {1}')
        else:
            unknown_fields = unknown_fields[0]

            msg = ('The back template ({0}) contains a field that is not present for this card, '
                   'or could not be resolved: \'{1}\''
                   if is_back_template else
                   'The template ({0}) contains a field that is not present for this card, '
                   'or could not be resolved: \'{1}\'')

        warn(msg.format(template_path, unknown_fields),
             in_context=context, cards_affected=cards_affected)

    @staticmethod
    def missing_fields_in_template(context: WarningContext,
                                   missing_fields: list,
                                   cards_affected: int,
                                   is_back_template: bool=False) -> None:
        if len(missing_fields) > 1:
            warning = ('The card back has unused columns: {0}'
                       if is_back_template else
                       'The card has unused columns: {0}')
        else:
            missing_fields = missing_fields[0]

            warning = ('The card back has an unused column: \'{0}\''
                       if is_back_template else
                       'The card has an unused column: \'{0}\'')

        warn(warning.format(missing_fields),
             in_context=context, cards_affected=cards_affected)

    @staticmethod
    def unused_definitions(unused_definitions: list) -> None:
        if len(unused_definitions) > 1:
            warning = 'You have unused definitions: {0}'
        else:
            unused_definitions = unused_definitions[0]

            warning = 'You have an unused definition: \'{0}\''

        warn(warning.format(unused_definitions))

    @staticmethod
    def invalid_columns_error(context: WarningContext,
                              invalid_columns: list) -> None:
        if len(invalid_columns) > 1:
            warning = 'Skipping datasource; some column names are invalid: {0}'
        else:
            invalid_columns = invalid_columns[0]

            warning = 'Skipping datasource; a column name is invalid: {0}'

        warn(warning.format(invalid_columns),
             in_context=context,
             as_error=True)

    @staticmethod
    def bad_data_path_error(context: WarningContext,
                            data_path: str) -> None:
        warn('The datasource could not be found at: {0}\'{1}\'{2}'
             .format(WarningDisplay.apply_error_color_underlined, data_path,
                     WarningDisplay.apply_error_color),
             in_context=context,
             as_error=True)

    @staticmethod
    def bad_template_path_error(context: WarningContext,
                                template_path: str,
                                cards_affected: int,
                                is_back: bool=False) -> None:
        warning = ('The card provided a back template that could not be opened: {0}\'{1}\'{2}'
                   if is_back else
                   'The card provided a template that could not be opened: {0}\'{1}\'{2}')

        warn(warning.format(WarningDisplay.apply_error_color_underlined, template_path,
                            WarningDisplay.apply_error_color),
             in_context=context, cards_affected=cards_affected,
             as_error=True)

    @staticmethod
    def abort_unusually_high_count(context: WarningContext,
                                   count: int) -> bool:
        # arbitrarily determined amount- but if the count is really high it might just be an error
        warn('The card has specified a high count: {0}. '
             'Are you sure you want to continue?'.format(count),
             in_context=context)

        answer = input('(Y)es or (n)o?').strip().lower()

        if answer == 'n' or answer == 'no':
            return True

        return False

    @staticmethod
    def bad_card_size(context: WarningContext,
                      size_identifier: str) -> None:
        warn('The card size \'{0}\' is invalid; defaulting to \'standard\''
             .format(size_identifier),
             in_context=context)

    @staticmethod
    def card_was_skipped_intentionally_info(context: WarningContext) -> None:
        info('The card was skipped (count was 0)',
             in_context=context)

    @staticmethod
    def referencing_excluded_row(context: WarningContext,
                                 referenced_row_number: int) -> None:
        warn('The column contains a field that references an excluded row (#{0})'
             .format(referenced_row_number),
             in_context=context)

    @staticmethod
    def referencing_row_out_of_bounds(context: WarningContext,
                                      referenced_row_number: int) -> None:
        warn('The column contains a field that references a row that is out of bounds (#{0})'
             .format(referenced_row_number),
             in_context=context)

    @staticmethod
    def referencing_row_header(context: WarningContext) -> None:
        warn('The column contains a field that references the header row (did you mean #2?)',
             in_context=context)

    @staticmethod
    def datasource_contains_filler_pages(context: WarningContext) -> None:
        warn('Other datasources contain cards backs, but this one does not; filler pages were '
             'inserted as necessary to keep all pages synchronized for two-sided printing',
             in_context=context) class WarningContext:  # pylint: disable=too-few-public-methods
    """ Represents the context of a warning. """

    def __init__(self,
                 name: str=None,
                 row_index: int=None,
                 card_index: int=None,
                 card_copy_index: int=None,
                 column: str=None):
        self.name = name
        self.row_index = row_index
        self.card_index = card_index
        self.card_copy_index = card_copy_index
        self.column = column

    def __repr__(self):
        return str(self)

    def __str__(self):
        if self.name is not None and len(self.name) > 0:
            if self.row_index is not None and self.row_index > -1:
                at_column = ':{0}'.format(self.column) if self.column is not None else ''

                if self.card_index is not None and self.card_index > -1:
                    if self.card_copy_index is not None and self.card_copy_index > -1:
                        return '[{0}:#{1}.{2}~{3}{4}]'.format(
                            self.name, self.row_index, self.card_copy_index, self.card_index,
                            at_column)
                    else:
                        return '[{0}:#{1}~{2}{3}]'.format(
                            self.name, self.row_index, self.card_index, at_column)
                else:
                    return '[{0}:#{1}{2}]'.format(
                        self.name, self.row_index, at_column)
            else:
                return '[{0}]'.format(
                    self.name)

        return '' class TemplateFields:  # pylint: disable=too-few-public-methods
    """ Reserved template field names. """

    # Required fields (system fields)

    # Populated by all generated pages
    PAGES = '_pages'
    # Populated by the current page number (1 being the first)
    PAGE_NUMBER = '_page_number'
    # Populated by the total number of pages
    PAGES_TOTAL = '_pages_total'
    # Populated by all generated cards on a single page
    CARDS = '_cards'
    # Populated by the CSS size class of a single card
    CARD_SIZE = '_card_size'
    # Populated by the content of a single card
    CARD_CONTENT = '_card_content'

    # Optional fields (user fields)

    # Populated by the current card index (1 being the first, also increments for duplicates)
    CARD_INDEX = '_card_index'
    # Populated by the current card copy index (1 being the first, not incremented for duplicates)
    CARD_COPY_INDEX = '_card_copy_index'
    # Populated by the row index of the current card (2 being the first- as CSV headers counts)
    CARD_ROW_INDEX = '_card_row_index'
    # Populated by the path of the template (if any) used to generate the current card
    CARD_TEMPLATE_PATH = '_card_template_path'
    # Populated by the total number of cards (including duplicates)
    CARDS_TOTAL = '_cards_total'
    # Populated by the total number of cards in the current datasource
    CARDS_TOTAL_IN_CONTEXT = '_cards_total_in_context'

    # Used to include the contents of a file (e.g. include 'path/to/file.html')
    INCLUDE = 'include'
    # Used to include the contents of a file (similar to 'include', except this strips any newlines)
    INLINE = 'inline'
    # Populated by the current time and date (can specify a date/time format; e.g. date '%d %m %Y')
    DATE = 'date'

    # Populated by the version of the project (as defined in a definitions source)
    VERSION = '_version'
    # Populated by the version of the program
    PROGRAM_VERSION = '_program_version'
    # Populated by the title of the project (as defined in a definitions source)
    TITLE = '_title'
    # Populated by the description of the project (as defined in a definitions source)
    DESCRIPTION = '_description'
    # Populated by the copyright notice of the project (as defined in a definitions source)
    COPYRIGHT = '_copyright'
    # Populated by the name of the author
    AUTHOR = '_author' class TemplateFieldDescriptors:  # pylint: disable=too-few-public-methods
    """ Descriptors that can be applied to template fields for special processing. """

    # An image field should copy the resource but not transform into an image tag
    COPY_ONLY = 'copy-only' class DateField:  # pylint: disable=too-few-public-methods
    # The date/time used whenever populating a date field, e.g. {{ date }}
    TODAY = datetime.date.today()
import os
import re
import datetime
from typing import List
from cards.templatefield import TemplateField, fields
from cards.column import (
    Row, get_column_contentd, get_definition_content, get_definition_contentd
)
from cards.resource import get_resource_path, is_image, supported_image_types, transformed_image_paths
from cards.util import first, dequote, get_line_number, get_padded_string
from cards.warning import WarningDisplay, WarningContext
from cards.constants import TemplateFields, TemplateFieldDescriptors, DateField
from cards.version import __version__

    pattern = r'<style.*?>(.+?)</style>'
    stripped_styles = ''

    search = re.compile(pattern, re.DOTALL)

    # find all style matches and extract embedded styles
    for style_match in re.finditer(pattern, template.content, re.DOTALL):
        # note that we strip the entire style- not the inner content
        style = style_match.group(0).strip()
        # separating each style block for good measure
        stripped_styles = stripped_styles + '\n' + style if len(stripped_styles) > 0 else style

    # finally remove all style matches
    # note that this removes the <style></style> tags too
    template.content = re.sub(search, '', template.content).strip()

    # make sure we keep it clean- no unnecessary newlines or excess whitespace
    stripped_styles = stripped_styles.strip()

    template_field_names = list((field.name for field in fields(stripped_styles)))

    if len(template_field_names) > 0:
        context = template.path

        # if there's any fields in the styles, display a warning about it
        WarningDisplay.fields_in_styles(
            WarningContext(context), template_field_names)

    return stripped_styles


def template_from_path(template_path: str,
                       relative_to_path: str=None) -> (str, bool, str):
    """ Return the template contents of the given path, if possible.

        If specified, path is made relative to another path.
    """

    template_content = None
    template_not_found = False

    if template_path is not None and len(template_path) > 0:
        if not os.path.isabs(template_path):
            # the path is not an absolute path; assume that it's located relative to the data
            if relative_to_path is not None:
                template_path = os.path.join(
                    os.path.dirname(relative_to_path),
                    template_path)

        try:
            with open(template_path) as template_file:
                template_content = template_file.read().strip()
        except IOError:
            template_not_found = True
    else:
        template_not_found = True

    return template_content, template_not_found, template_path


def image_size(image_path: str, from_context: str) -> (int, int):
    """ Return the size specified by the context of an image field. """

    explicit_width = None

    # get each size specification separately (removing blanks)
    size_components = list(filter(None, from_context.split('x')))

    if len(size_components) > 0:
        width_specification = size_components[0]

        try:
            explicit_width = int(width_specification)
        except ValueError:
            explicit_width = None

            WarningDisplay.unknown_size_specification(
                WarningContext(image_path), from_context)
        else:
            if explicit_width < 0:
                WarningDisplay.invalid_width_specification(
                    WarningContext(image_path), explicit_width)

                explicit_width = None

    if len(size_components) > 1:
        height_specification = size_components[1]

        try:
            explicit_height = int(height_specification)
        except ValueError:
            explicit_height = None

            WarningDisplay.unknown_size_specification(
                WarningContext(image_path), from_context)
        else:
            if explicit_height < 0:
                WarningDisplay.invalid_height_specification(
                    WarningContext(image_path), explicit_height)

                explicit_height = None
    else:
        # default to a squared size using the width specification
        explicit_height = explicit_width

    return explicit_width, explicit_height


def image(field: TemplateField) -> (str, str):
    """ Transform an image field into an image tag, unless field specifies otherwise.  """

    image_path = field.name

    no_transform = False

    width = None
    height = None

    if field.context is not None:

        if field.context == TemplateFieldDescriptors.COPY_ONLY: